From 6d63bd6f62f031fac64fba6275462ebbc20f1eb7 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 16 Jun 2026 17:54:02 -0400 Subject: [PATCH 1/7] ci: add reusable cross-repo workspace test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `go-workspace-test.yml`, a reusable (workflow_call) workflow that tests a repo against its fil-forge sibling repos' matching branches, plus a thin caller. When a PR's branch name also exists on a sibling repo this module depends on (coordinated change sets share a branch name), the workflow clones those matching-branch siblings, synthesizes a go.work over them, and runs the repo's own `make test` against the integrated workspace. The matching repos appear in the check name on the PR. When no sibling has a matching branch, the test job is skipped — the normal per-repo go-test.yml already covers that case. This is informational feedback, not a merge gate: go.mod stays on the published sibling versions until they land, so go-test.yml remains the real blocker. The job is not posted as a status and should not be a required check. libforge hosts the reusable workflow; sibling repos call it at @v1. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/go-workspace-test.yml | 209 ++++++++++++++++++++++++ .github/workflows/workspace-test.yml | 17 ++ 2 files changed, 226 insertions(+) create mode 100644 .github/workflows/go-workspace-test.yml create mode 100644 .github/workflows/workspace-test.yml diff --git a/.github/workflows/go-workspace-test.yml b/.github/workflows/go-workspace-test.yml new file mode 100644 index 0000000..0dbd5ed --- /dev/null +++ b/.github/workflows/go-workspace-test.yml @@ -0,0 +1,209 @@ +# Reusable workspace CI for the fil-forge repo set. +# +# Called by a thin `workspace-test.yml` in each fil-forge repo. It reads the +# CALLING repo's go.mod, finds its `github.com/fil-forge/*` dependencies, and +# checks whether any of those sibling repos has a branch matching THIS PR's +# branch (coordinated change sets share a branch name across repos). +# +# - If NO sibling has a matching branch, the workspace-test job is SKIPPED +# (grey, a non-event). The repo's normal go-test.yml already tests against +# the published go.mod versions and is the real signal there. +# - If >=1 sibling matches, it clones ONLY those matching-branch siblings, +# synthesizes a go.work over them, and runs the tests against that +# integrated workspace. The matching repos are shown in the check's name on +# the PR. +# +# This job is INFORMATIONAL, not a merge gate: the convention is that a +# coordinated PR keeps its go.mod on the published sibling versions until the +# siblings land, so the normal go-test.yml is the blocker. Do NOT make this a +# required status check (a skipped required check would block no-match PRs). +# +# All fil-forge repos are public, so the built-in GITHUB_TOKEN can read the +# siblings. No status is posted, so only contents:read is needed. + +name: Go Workspace Test (reusable) + +on: + workflow_call: + inputs: + go-version: + description: 'Go toolchain to install. Must be >= the max `go` directive across all workspace members (guppy pins 1.26.1).' + type: string + default: 'stable' + +permissions: + contents: read + +env: + HEAD_REF: ${{ github.head_ref }} + GH_TOKEN: ${{ github.token }} + +jobs: + # --- Cheap probe: which sibling repos have a branch matching this PR? ------- + detect: + runs-on: ubuntu-latest + outputs: + matched: ${{ steps.probe.outputs.matched }} + repos: ${{ steps.probe.outputs.repos }} + tsv_b64: ${{ steps.probe.outputs.tsv_b64 }} + steps: + - name: Checkout primary repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + cache: false + + # Parse THIS go.mod for fil-forge deps (direct + indirect). Emit + # modulereposubdir. repo = first path segment after the org; + # subdir = the rest (so filecoin-services/go -> repo filecoin-services, + # dir go/). Then ls-remote each unique repo for the PR branch — one + # round-trip, no clone. + - name: Probe sibling branches + id: probe + run: | + set -euo pipefail + + # No PR branch (e.g. push to main) -> nothing to match. + if [ -z "${HEAD_REF:-}" ]; then + echo "no head_ref; nothing to match" + { + echo "matched=false" + echo "repos=" + echo "tsv_b64=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + go mod edit -json \ + | jq -r ' + .Require[]? + | select(.Path | startswith("github.com/fil-forge/")) + | .Path' \ + | while read -r mod; do + rest=${mod#github.com/fil-forge/} + repo=${rest%%/*} + if [ "$repo" = "$rest" ]; then sub="."; else sub=${rest#*/}; fi + printf '%s\t%s\t%s\n' "$mod" "$repo" "$sub" + done > deps.tsv + echo "fil-forge deps:"; cat deps.tsv || true + + auth_url() { echo "https://x-access-token:${GH_TOKEN}@github.com/fil-forge/$1.git"; } + + : > matched.tsv + matched_repos="" + seen="" + while IFS=$'\t' read -r mod repo sub; do + [ -z "$repo" ] && continue + case " $seen " in *" $repo "*) ;; *) + seen="$seen $repo" + if git ls-remote --exit-code --heads "$(auth_url "$repo")" "$HEAD_REF" >/dev/null 2>&1; then + echo "match: $repo has branch $HEAD_REF" + matched_repos="$matched_repos $repo" + fi + ;; esac + done < deps.tsv + + # Keep only the dep lines whose repo matched (a repo may host >1 module). + while IFS=$'\t' read -r mod repo sub; do + case " $matched_repos " in *" $repo "*) printf '%s\t%s\t%s\n' "$mod" "$repo" "$sub" >> matched.tsv ;; esac + done < deps.tsv + + if [ -n "$matched_repos" ]; then + echo "matched=true" >> "$GITHUB_OUTPUT" + else + echo "matched=false" >> "$GITHUB_OUTPUT" + fi + # Display list: comma-separated, trimmed. + echo "repos=$(echo "$matched_repos" | xargs | sed 's/ /, /g')" >> "$GITHUB_OUTPUT" + echo "tsv_b64=$(base64 -w0 < matched.tsv 2>/dev/null || base64 < matched.tsv | tr -d '\n')" >> "$GITHUB_OUTPUT" + + # --- Only runs when >=1 sibling matched; otherwise shows as skipped -------- + workspace-test: + needs: detect + if: needs.detect.outputs.matched == 'true' + name: Workspace test (${{ needs.detect.outputs.repos }}) + runs-on: ubuntu-latest + steps: + - name: Checkout primary repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go-version }} + cache: false + + # Clone only the matching-branch siblings (de-duped per repo). + - name: Clone matching siblings + run: | + set -euo pipefail + mkdir -p _siblings + base64 -d <<< '${{ needs.detect.outputs.tsv_b64 }}' > matched.tsv || true + + auth_url() { echo "https://x-access-token:${GH_TOKEN}@github.com/fil-forge/$1.git"; } + seen="" + while IFS=$'\t' read -r _mod repo _sub; do + [ -z "$repo" ] && continue + case " $seen " in *" $repo "*) continue ;; esac + seen="$seen $repo" + git clone --depth 1 --branch "$HEAD_REF" "$(auth_url "$repo")" "_siblings/$repo" 2>&1 \ + | sed 's#x-access-token:[^@]*@#x-access-token:***@#g' + echo "cloned $repo @ $HEAD_REF" + done < matched.tsv + + - name: Synthesize go.work + run: | + set -euo pipefail + base64 -d <<< '${{ needs.detect.outputs.tsv_b64 }}' > matched.tsv || true + + # Module dirs to `go work use` (handles subdir modules). + members="" + while IFS=$'\t' read -r _mod repo sub; do + [ -d "_siblings/$repo" ] || continue + if [ "$sub" = "." ]; then p="./_siblings/$repo"; else p="./_siblings/$repo/$sub"; fi + [ -f "$p/go.mod" ] && members="$members $p" + done < matched.tsv + + # Workspace `go` directive must be >= the max across all members. + maxver="$(go mod edit -json | jq -r '.Go')" + for m in $members; do + v="$(cd "$m" && go mod edit -json | jq -r '.Go')" + maxver="$(printf '%s\n%s\n' "$maxver" "$v" | sort -V | tail -1)" + done + echo "workspace go directive -> $maxver" + + rm -f go.work go.work.sum + go work init + go work edit -go "$maxver" + go work use . + for m in $members; do go work use "$m"; done + + # Replicate the parent go.work's replace so CI matches local dev. + go work edit -replace google.golang.org/genproto=google.golang.org/genproto@v0.0.0-20260526163538-3dc84a4a5aaa + + echo "----- go.work -----"; cat go.work + go work sync || true + + # Run the repo's own `make test` target so the test command stays a single + # source of truth per repo. It runs from the repo root and inherits the + # synthesized go.work via cwd (do NOT set GOWORK=off), so the matching + # sibling branches are what gets tested. Linting is intentionally NOT run + # here — that's each repo's separate go-check.yml signal. + - name: make test (against the workspace) + run: make test + + - name: Summary + if: ${{ always() }} + run: | + { + echo "### Workspace test" + echo "" + echo "Tested against matching sibling branches (\`$HEAD_REF\`): **${{ needs.detect.outputs.repos }}**" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/workspace-test.yml b/.github/workflows/workspace-test.yml new file mode 100644 index 0000000..830c28a --- /dev/null +++ b/.github/workflows/workspace-test.yml @@ -0,0 +1,17 @@ +name: Workspace Test + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: workspace-test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + workspace-test: + # libforge hosts the reusable workflow, so it calls its own copy locally. + uses: ./.github/workflows/go-workspace-test.yml From 3c8767a488cc836c14d1376cce78bad4a387d3b0 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Wed, 3 Jun 2026 18:07:31 -0400 Subject: [PATCH 2/7] feat: Support attested signatures --- {didmailto => attestation/didmailto}/did.go | 2 +- .../didmailto}/did_test.go | 0 attestation/didmailto/didmailto.go | 3 + attestation/didmailto/resolver.go | 37 ++ attestation/signer.go | 62 ++ attestation/signer_test.go | 79 +++ attestation/varsig.go | 44 ++ attestation/verificationmethod.go | 83 +++ attestation/verifier.go | 70 +++ blobindex/shardeddagindex_test.go | 10 +- didresolver/cacheresolver.go | 33 - didresolver/cacheresolver_test.go | 324 ---------- didresolver/httpresolver.go | 268 -------- didresolver/httpresolver_test.go | 593 ------------------ didresolver/mapresolver.go | 57 -- didresolver/mapresolver_test.go | 33 - didresolver/self.go | 32 - didresolver/self_test.go | 61 -- didresolver/tieredresolver.go | 38 -- didresolver/tieredresolver_test.go | 198 ------ go.mod | 13 +- go.sum | 18 +- identity/identity.go | 121 ++++ identity/pem.go | 20 +- identity/pem_test.go | 12 +- testutil/fixtures.go | 41 +- testutil/gen.go | 41 +- ucan/attestations.go | 58 -- ucan/attestations_test.go | 183 ------ ucan/proof_chain_test.go | 16 +- ucan/proof_store.go | 36 +- ucan/proof_store_test.go | 226 ++++--- ucan/retrieval/client_test.go | 15 +- ucan/retrieval/server.go | 5 +- ucan/retrieval/server_test.go | 4 +- 35 files changed, 719 insertions(+), 2117 deletions(-) rename {didmailto => attestation/didmailto}/did.go (93%) rename {didmailto => attestation/didmailto}/did_test.go (100%) create mode 100644 attestation/didmailto/didmailto.go create mode 100644 attestation/didmailto/resolver.go create mode 100644 attestation/signer.go create mode 100644 attestation/signer_test.go create mode 100644 attestation/varsig.go create mode 100644 attestation/verificationmethod.go create mode 100644 attestation/verifier.go delete mode 100644 didresolver/cacheresolver.go delete mode 100644 didresolver/cacheresolver_test.go delete mode 100644 didresolver/httpresolver.go delete mode 100644 didresolver/httpresolver_test.go delete mode 100644 didresolver/mapresolver.go delete mode 100644 didresolver/mapresolver_test.go delete mode 100644 didresolver/self.go delete mode 100644 didresolver/self_test.go delete mode 100644 didresolver/tieredresolver.go delete mode 100644 didresolver/tieredresolver_test.go create mode 100644 identity/identity.go delete mode 100644 ucan/attestations.go delete mode 100644 ucan/attestations_test.go diff --git a/didmailto/did.go b/attestation/didmailto/did.go similarity index 93% rename from didmailto/did.go rename to attestation/didmailto/did.go index cbf3a20..41453e5 100644 --- a/didmailto/did.go +++ b/attestation/didmailto/did.go @@ -27,7 +27,7 @@ func New(email string) (did.DID, error) { return did.DID{}, fmt.Errorf("malformed email address: %s", email) } local, domain = a.Address[:at], a.Address[at+1:] - return did.Parse(fmt.Sprintf("did:mailto:%s:%s", url.QueryEscape(domain), url.QueryEscape(local))) + return did.New(Method, fmt.Sprintf("%s:%s", url.QueryEscape(domain), url.QueryEscape(local))), nil } // Email extracts the email address from the DID. diff --git a/didmailto/did_test.go b/attestation/didmailto/did_test.go similarity index 100% rename from didmailto/did_test.go rename to attestation/didmailto/did_test.go diff --git a/attestation/didmailto/didmailto.go b/attestation/didmailto/didmailto.go new file mode 100644 index 0000000..b7c3c4a --- /dev/null +++ b/attestation/didmailto/didmailto.go @@ -0,0 +1,3 @@ +package didmailto + +const Method = "mailto" diff --git a/attestation/didmailto/resolver.go b/attestation/didmailto/resolver.go new file mode 100644 index 0000000..cd30b12 --- /dev/null +++ b/attestation/didmailto/resolver.go @@ -0,0 +1,37 @@ +package didmailto + +import ( + "context" + + "github.com/fil-forge/libforge/attestation" + "github.com/fil-forge/ucantone/did" +) + +func NewResolver(authority did.DID) did.ResolverFunc { + return func(_ context.Context, d did.DID) (did.Document, error) { + if d.Method() != Method { + return did.Document{}, did.MethodNotSupportedError{Method: d.Method()} + } + + doc := did.NewDocument(d) + vm := did.VerificationMethod{ + ID: doc.Fragment(authority.String()), + Controller: authority, + Type: attestation.Type, + Material: did.GenericMap{attestation.AuthorityProp: authority.String()}, + } + + if err := doc.VerificationMethods.Add(vm); err != nil { + return did.Document{}, err + } + + if err := doc.CapabilityDelegation.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.CapabilityInvocation.Add(vm); err != nil { + return did.Document{}, err + } + + return doc, nil + } +} diff --git a/attestation/signer.go b/attestation/signer.go new file mode 100644 index 0000000..b696368 --- /dev/null +++ b/attestation/signer.go @@ -0,0 +1,62 @@ +package attestation + +import ( + "context" + "fmt" + + "github.com/fil-forge/libforge/commands/ucan/attest" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/varsig" + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +func Attest(ctx context.Context, subject did.DID, authority ucan.Issuer) Issuer { + return Issuer{ + ctx: ctx, + did: subject, + authority: authority, + } +} + +type Issuer struct { + ctx context.Context + did did.DID + authority ucan.Issuer +} + +var _ ucan.Issuer = Issuer{} + +func (s Issuer) DID() did.DID { + return s.did +} + +func (s Issuer) String() string { + return fmt.Sprintf("%s (attested by %s)", s.did, s.authority.DID()) +} + +func (s Issuer) Sign(data []byte) []byte { + msgDigest, err := mh.Sum(data, mh.SHA2_256, -1) + if err != nil { + panic(fmt.Sprintf("failed to compute message digest: %v", err)) + } + + inv, err := attest.Proof.Invoke( + s.authority, + s.authority.DID(), + &attest.ProofArguments{Proof: cid.NewCidV1(cid.Raw, msgDigest)}, + ) + if err != nil { + panic(fmt.Sprintf("failed to create invocation: %v", err)) + } + return inv.Bytes() +} + +func (s Issuer) SignatureAlgorithm() varsig.Algorithm { + return Algorithm{} +} + +func (s Issuer) Verifier() ucan.Verifier { + return AttestedVerifier(s.ctx, s.authority.DID(), s.authority.Verifier()) +} diff --git a/attestation/signer_test.go b/attestation/signer_test.go new file mode 100644 index 0000000..e9e5d09 --- /dev/null +++ b/attestation/signer_test.go @@ -0,0 +1,79 @@ +package attestation_test + +import ( + "bytes" + "testing" + + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/validator" + + "github.com/fil-forge/libforge/attestation" + "github.com/fil-forge/libforge/attestation/didmailto" + "github.com/fil-forge/libforge/commands/ucan/attest" + "github.com/fil-forge/libforge/testutil" +) + +func TestSigner(t *testing.T) { + authority := testutil.RandomIssuer(t) + alice, err := did.Parse("did:mailto:example.com:alice") + require.NoError(t, err) + + issuer := attestation.Attest(t.Context(), alice, authority) + + del, err := delegation.Delegate( + issuer, + testutil.RandomDID(t), + issuer.DID(), + command.MustParse("/example/command"), + ) + require.NoError(t, err) + + t.Run("signs data correctly", func(t *testing.T) { + require.Equal(t, del.Signature().Header().SignatureAlgorithm(), attestation.Algorithm{}) + sigBytes := del.Signature().Bytes() + require.NotEmpty(t, sigBytes) + + inv, err := invocation.Decode(sigBytes) + require.NoError(t, err) + + require.Equal(t, authority.DID(), inv.Issuer()) + require.Equal(t, did.Undef, inv.Audience()) + require.Equal(t, authority.DID(), inv.Subject()) + require.Equal(t, attest.Proof.Command, inv.Command()) + + msgDigest, err := mh.Sum(del.SignedBytes(), mh.SHA2_256, -1) + require.NoError(t, err) + var proofArgs attest.ProofArguments + err = proofArgs.UnmarshalCBOR(bytes.NewReader(inv.ArgumentsBytes())) + require.NoError(t, err) + require.Equal(t, attest.ProofArguments{Proof: cid.NewCidV1(cid.Raw, msgDigest)}, proofArgs) + }) + + t.Run("delegation round-trips through CBOR and verifies", func(t *testing.T) { + encoded, err := delegation.Encode(del) + require.NoError(t, err) + + decoded, err := delegation.Decode(encoded) + require.NoError(t, err) + + resolver := did.ResolverMap{ + "key": key.Resolver, + "mailto": didmailto.NewResolver(authority.DID()), + } + factories := validator.DefaultFactories() + factories[attestation.Type] = attestation.NewVerifierFactory(resolver, factories) + err = validator.ValidateToken(t.Context(), decoded, + validator.WithDIDResolver(resolver), + validator.WithVerifierFactories(factories), + ) + require.NoError(t, err) + }) +} diff --git a/attestation/varsig.go b/attestation/varsig.go new file mode 100644 index 0000000..4201db2 --- /dev/null +++ b/attestation/varsig.go @@ -0,0 +1,44 @@ +package attestation + +import ( + "fmt" + + "github.com/fil-forge/ucantone/varsig" + "github.com/fil-forge/ucantone/varsig/algorithm" + "github.com/multiformats/go-varint" +) + +func init() { + // Register spec-defined signature algorithms. + varsig.RegisterAlgorithmScheme(algorithm.AlgorithmDef{ + Code: Code, + Name: "Attested Authority", + Decoder: DecodeAlgoithm, + }) +} + +// Code is the Varsig signature algorithm code for attested signatures, under +// fil-one RFC 7. Note that the Varsig signature algorithm codes are *not* +// Multicodec codes! Officially, the Varsig code table makes no provision for +// extension, but we've selected a code in *Multicodec's* "private use" range, +// on the theory that it should be safe. +const Code uint64 = 0x300001 + +type Algorithm struct{} + +var algorithmInstance algorithm.Algorithm = Algorithm{} + +func (alg Algorithm) Encode() ([]byte, error) { + return varint.ToUvarint(Code), nil +} + +func DecodeAlgoithm(input []byte) (algorithm.Algorithm, int, error) { + code, n, err := varint.FromUvarint(input) + if err != nil { + return nil, 0, err + } + if code != Code { + return nil, n, fmt.Errorf("signature code is not attested-authority: 0x%02x, expected: 0x%02x", code, Code) + } + return algorithmInstance, n, nil +} diff --git a/attestation/verificationmethod.go b/attestation/verificationmethod.go new file mode 100644 index 0000000..6974214 --- /dev/null +++ b/attestation/verificationmethod.go @@ -0,0 +1,83 @@ +package attestation + +import ( + "context" + "fmt" + "strings" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/verification" +) + +var ( + Type = "AuthorityAttestation" + AuthorityProp = "authority" +) + +// NewVerifierFactory returns a [verification.Factory] for AuthorityAttestation +// verification methods. Pass it to the validator via +// [validator.WithVerifierFactories]. The provided DID resolver and +// verifierFactories are used to derive verifiers for the authority's own +// verification methods. +func NewVerifierFactory(resolver did.Resolver, verifierFactories map[string]verification.Factory) verification.Factory { + return func(ctx context.Context, mat did.VerificationMaterial) (ucan.Verifier, error) { + authorityDidStr, ok := mat[AuthorityProp].(string) + if !ok { + return nil, fmt.Errorf("AuthorityAttestation verification method missing %s", AuthorityProp) + } + authorityDid, err := did.Parse(authorityDidStr) + if err != nil { + return nil, fmt.Errorf("failed to parse authority DID: %w", err) + } + doc, err := resolver.Resolve(ctx, authorityDid) + if err != nil { + return nil, fmt.Errorf("failed to resolve authority DID %s: %w", authorityDid, err) + } + + v, err := newMultiVerifier(ctx, verifierFactories, doc.CapabilityInvocation.All()) + if err != nil { + return nil, fmt.Errorf("failed to derive multi-verifier: %w", err) + } + return AttestedVerifier(ctx, authorityDid, v), nil + } +} + +// multiVerifier is a [ucan.Verifier] that verifies a signature if any of its +// component verifiers verify it. This is useful for cases where a token's +// issuer has multiple verification methods that could have been used to sign +// the token, and the verifier doesn't know which one was used. +type multiVerifier []ucan.Verifier + +func (m multiVerifier) Verify(data []byte, sig []byte) bool { + for _, v := range m { + if v.Verify(data, sig) { + return true + } + } + return false +} + +func (m multiVerifier) String() string { + var str strings.Builder + for _, v := range m { + str.WriteString(v.String()) + } + return fmt.Sprintf("multiVerifier{%d verifiers: %s}", len(m), str.String()) +} + +func newMultiVerifier(ctx context.Context, registry map[string]verification.Factory, vms []did.VerificationMethod) (ucan.Verifier, error) { + verifiers := make([]ucan.Verifier, 0, len(vms)) + for _, vm := range vms { + f, ok := registry[vm.Type] + if !ok { + return nil, fmt.Errorf("%w for VM type %q", verification.ErrNoVerifierFactory, vm.Type) + } + v, err := f(ctx, vm.Material) + if err != nil { + return nil, fmt.Errorf("deriving verifier for VM %s: %w", vm.ID, err) + } + verifiers = append(verifiers, v) + } + return multiVerifier(verifiers), nil +} diff --git a/attestation/verifier.go b/attestation/verifier.go new file mode 100644 index 0000000..e2e0fee --- /dev/null +++ b/attestation/verifier.go @@ -0,0 +1,70 @@ +package attestation + +import ( + "bytes" + "context" + "fmt" + + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + + "github.com/fil-forge/libforge/commands/ucan/attest" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/fil-forge/ucantone/validator" +) + +// Verifier is a ucan.Verifier for a DID whose signing is attested by an +// authority verifier. +type Verifier struct { + ctx context.Context + authorityID did.DID + authorityVerifier ucan.Verifier +} + +var _ ucan.Verifier = Verifier{} + +func (v Verifier) String() string { + return fmt.Sprintf("Attested Verifier{authority=%s}", v.authorityID) +} + +func (v Verifier) Verify(msg []byte, sig []byte) bool { + inv, err := invocation.Decode(sig) + if err != nil { + return false + } + + var args attest.ProofArguments + err = args.UnmarshalCBOR(bytes.NewReader(inv.ArgumentsBytes())) + if err != nil { + return false + } + + msgDigest, err := mh.Sum(msg, mh.SHA2_256, -1) + if err != nil { + return false + } + + if args.Proof != cid.NewCidV1(cid.Raw, msgDigest) { + return false + } + + if inv.Subject() != v.authorityID { + return false + } + + if validator.ValidateInvocation(v.ctx, inv) != nil { + return false + } + + return true +} + +func AttestedVerifier(ctx context.Context, authorityID did.DID, authority ucan.Verifier) ucan.Verifier { + return Verifier{ + ctx: ctx, + authorityID: authorityID, + authorityVerifier: authority, + } +} diff --git a/blobindex/shardeddagindex_test.go b/blobindex/shardeddagindex_test.go index ad19c19..0b81069 100644 --- a/blobindex/shardeddagindex_test.go +++ b/blobindex/shardeddagindex_test.go @@ -18,13 +18,13 @@ func TestFromToArchive(t *testing.T) { byteRange []int }{ testutil.RandomCID(t): { - {digest: testutil.RandomMultihash(t), byteRange: []int{0, 99}}, - {digest: testutil.RandomMultihash(t), byteRange: []int{100, 199}}, - {digest: testutil.RandomMultihash(t), byteRange: []int{200, 299}}, + {digest: testutil.RandomDigest(t), byteRange: []int{0, 99}}, + {digest: testutil.RandomDigest(t), byteRange: []int{100, 199}}, + {digest: testutil.RandomDigest(t), byteRange: []int{200, 299}}, }, testutil.RandomCID(t): { - {digest: testutil.RandomMultihash(t), byteRange: []int{0, 34}}, - {digest: testutil.RandomMultihash(t), byteRange: []int{35, 58}}, + {digest: testutil.RandomDigest(t), byteRange: []int{0, 34}}, + {digest: testutil.RandomDigest(t), byteRange: []int{35, 58}}, }, } diff --git a/didresolver/cacheresolver.go b/didresolver/cacheresolver.go deleted file mode 100644 index 6b1a4d9..0000000 --- a/didresolver/cacheresolver.go +++ /dev/null @@ -1,33 +0,0 @@ -package didresolver - -import ( - "context" - "time" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/ucan" - "github.com/patrickmn/go-cache" -) - -type CachedResolver struct { - wrapped DIDVerifierResolverFunc - cache *cache.Cache -} - -func NewCachedResolver(wrapped DIDVerifierResolverFunc, ttl time.Duration) (*CachedResolver, error) { - // items remain in the cache for `ttl`, expired items are purged every hour. - return &CachedResolver{wrapped: wrapped, cache: cache.New(ttl, time.Hour)}, nil -} - -func (c *CachedResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { - if out, found := c.cache.Get(input.String()); found { - return out.(ucan.Verifier), nil - } - out, err := c.wrapped(ctx, input) - if err != nil { - return nil, err - } - c.cache.Set(input.String(), out, cache.DefaultExpiration) - - return out, nil -} diff --git a/didresolver/cacheresolver_test.go b/didresolver/cacheresolver_test.go deleted file mode 100644 index fb4a665..0000000 --- a/didresolver/cacheresolver_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package didresolver_test - -import ( - "context" - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/fil-forge/libforge/didresolver" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" - "github.com/fil-forge/ucantone/ucan" - verrs "github.com/fil-forge/ucantone/validator/errors" - "github.com/stretchr/testify/require" -) - -type mockResolver struct { - resolveFn didresolver.DIDVerifierResolverFunc - callCount int32 -} - -func (m *mockResolver) ResolveDIDKey(ctx context.Context, input did.DID) (ucan.Verifier, error) { - atomic.AddInt32(&m.callCount, 1) - if m.resolveFn != nil { - return m.resolveFn(ctx, input) - } - return nil, fmt.Errorf("mock error") -} - -func (m *mockResolver) getCallCount() int { - return int(atomic.LoadInt32(&m.callCount)) -} - -func TestNewCachedResolver(t *testing.T) { - t.Run("creates resolver with valid TTL", func(t *testing.T) { - mockWrapped := &mockResolver{} - resolver, err := didresolver.NewCachedResolver(mockWrapped.ResolveDIDKey, 5*time.Minute) - require.NoError(t, err) - require.NotNil(t, resolver) - }) - - t.Run("creates resolver with zero TTL", func(t *testing.T) { - mockWrapped := &mockResolver{} - resolver, err := didresolver.NewCachedResolver(mockWrapped.ResolveDIDKey, 0) - require.NoError(t, err) - require.NotNil(t, resolver) - }) -} - -func TestCachedResolver_ResolveDIDKey(t *testing.T) { - t.Run("caches successful resolution", func(t *testing.T) { - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - - mock := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - if input.String() == didWeb.String() { - return didKey, nil - } - return nil, fmt.Errorf("not found") - }, - } - - resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 100*time.Millisecond) - require.NoError(t, err) - - // First call should hit the wrapped resolver - result1, err1 := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, err1) - require.Equal(t, didKey, result1) - require.Equal(t, 1, mock.getCallCount()) - - // Second call should use cache - result2, err2 := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, err2) - require.Equal(t, didKey, result2) - require.Equal(t, 1, mock.getCallCount()) // No additional call - - // Wait for cache to expire - time.Sleep(150 * time.Millisecond) - - // Third call should hit the wrapped resolver again - result3, err3 := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, err3) - require.Equal(t, didKey, result3) - require.Equal(t, 2, mock.getCallCount()) - }) - - t.Run("does not cache errors", func(t *testing.T) { - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - mock := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution failed")) - }, - } - - resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 100*time.Millisecond) - require.NoError(t, err) - - // First call - result1, err1 := resolver.Resolve(t.Context(), didWeb) - require.NotNil(t, err1) - require.Nil(t, result1) - require.Equal(t, 1, mock.getCallCount()) - - // Second call should still hit the wrapped resolver (errors not cached) - result2, err2 := resolver.Resolve(t.Context(), didWeb) - require.NotNil(t, err2) - require.Nil(t, result2) - require.Equal(t, 2, mock.getCallCount()) - }) - - t.Run("handles concurrent access", func(t *testing.T) { - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - - var resolverCalls int32 - mock := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - atomic.AddInt32(&resolverCalls, 1) - time.Sleep(10 * time.Millisecond) // Simulate slow resolution - return didKey, nil - }, - } - - resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) - require.NoError(t, err) - - var wg sync.WaitGroup - results := make([]ucan.Verifier, 10) - errors := make([]error, 10) - - // Launch 10 concurrent requests - for i := 0; i < 10; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - results[idx], errors[idx] = resolver.Resolve(t.Context(), didWeb) - }(i) - } - - wg.Wait() - - // All should succeed with the same result - for i := 0; i < 10; i++ { - require.Nil(t, errors[i]) - require.Equal(t, didKey, results[i]) - } - - // Due to caching, we expect fewer calls than requests - // But with very fast concurrent access, all 10 might hit before the first one finishes - actualCalls := atomic.LoadInt32(&resolverCalls) - require.LessOrEqual(t, actualCalls, int32(10)) - // But at least we should have gotten some caching benefit on subsequent calls - // Let's do another call to verify caching is working - result, err := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, err) - require.Equal(t, didKey, result) - // This call should definitely use the cache - finalCalls := atomic.LoadInt32(&resolverCalls) - require.Equal(t, actualCalls, finalCalls) - }) - - t.Run("handles different DIDs independently", func(t *testing.T) { - did1, err := did.Parse("did:web:example1.com") - require.NoError(t, err) - - did2, err := did.Parse("did:web:example2.com") - require.NoError(t, err) - - didKey1, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - - didKey2, err := verifier.Parse("did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6") - require.NoError(t, err) - - mock := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - switch input.String() { - case did1.String(): - return didKey1, nil - case did2.String(): - return didKey2, nil - default: - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("unknown DID")) - } - }, - } - - resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) - require.NoError(t, err) - - // Resolve first DID - result1, err1 := resolver.Resolve(t.Context(), did1) - require.Nil(t, err1) - require.Equal(t, didKey1, result1) - require.Equal(t, 1, mock.getCallCount()) - - // Resolve second DID - result2, err2 := resolver.Resolve(t.Context(), did2) - require.Nil(t, err2) - require.Equal(t, didKey2, result2) - require.Equal(t, 2, mock.getCallCount()) - - // Resolve first DID again (should be cached) - result3, err3 := resolver.Resolve(t.Context(), did1) - require.Nil(t, err3) - require.Equal(t, didKey1, result3) - require.Equal(t, 2, mock.getCallCount()) // No additional call - - // Resolve second DID again (should be cached) - result4, err4 := resolver.Resolve(t.Context(), did2) - require.Nil(t, err4) - require.Equal(t, didKey2, result4) - require.Equal(t, 2, mock.getCallCount()) // No additional call - }) -} - -func TestCachedResolver_WithFixedImplementation(t *testing.T) { - // This test verifies the bug is fixed - t.Run("wrapped resolver works correctly", func(t *testing.T) { - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - - mock := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return didKey, nil - }, - } - - resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) - require.NoError(t, err) - - // Should not panic and should return the expected result - result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, unresolvedErr) - require.Equal(t, didKey, result) - }) -} - -func TestCachedResolver_WithMapResolver(t *testing.T) { - t.Run("caches MapResolver lookups", func(t *testing.T) { - // Create a mapping of DIDs - mapping := map[string]string{ - "did:web:alice.example.com": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - "did:web:bob.example.com": "did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6", - "did:web:carol.example.com": "did:key:z6MkwXG2WjeQnNxSoynSGYU8V9j3QzP3JSqhdmkHc6SaVWoV", - } - - // Create MapResolver - mapResolver, err := didresolver.NewMapResolver(mapping) - require.NoError(t, err) - - // Wrap it with CacheResolver - cachedResolver, err := didresolver.NewCachedResolver(mapResolver.Resolve, 200*time.Millisecond) - require.NoError(t, err) - - // Test alice - aliceDID, err := did.Parse("did:web:alice.example.com") - require.NoError(t, err) - - // MapResolver now wraps verifiers as the requested DID — see - // ucantone/ucan/token/token.go for why. The cached verifier's DID() - // should match the input DID, not the underlying did:key. - - // First call - should hit MapResolver - result1, err1 := cachedResolver.Resolve(t.Context(), aliceDID) - require.Nil(t, err1) - require.Equal(t, aliceDID, result1.DID()) - - // Second call - should use cache (we can't directly verify this without instrumentation) - result2, err2 := cachedResolver.Resolve(t.Context(), aliceDID) - require.Nil(t, err2) - require.Equal(t, aliceDID, result2.DID()) - - // Test bob while alice is still cached - bobDID, err := did.Parse("did:web:bob.example.com") - require.NoError(t, err) - - result3, err3 := cachedResolver.Resolve(t.Context(), bobDID) - require.Nil(t, err3) - require.Equal(t, bobDID, result3.DID()) - - // Wait for cache to expire - time.Sleep(250 * time.Millisecond) - - // Alice's entry should have expired, this should hit MapResolver again - result4, err4 := cachedResolver.Resolve(t.Context(), aliceDID) - require.Nil(t, err4) - require.Equal(t, aliceDID, result4.DID()) - - // Test non-existent DID - unknownDID, err := did.Parse("did:web:unknown.example.com") - require.NoError(t, err) - - result5, err5 := cachedResolver.Resolve(t.Context(), unknownDID) - require.NotNil(t, err5) - require.Nil(t, result5) - require.Contains(t, err5.Error(), "unable to resolve") - }) - - t.Run("handles invalid mappings gracefully", func(t *testing.T) { - // Test with invalid DID in mapping - invalidMapping := map[string]string{ - "invalid-did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - } - - _, err := didresolver.NewMapResolver(invalidMapping) - require.Error(t, err) - }) -} diff --git a/didresolver/httpresolver.go b/didresolver/httpresolver.go deleted file mode 100644 index e81aa63..0000000 --- a/didresolver/httpresolver.go +++ /dev/null @@ -1,268 +0,0 @@ -package didresolver - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" - pverifier "github.com/fil-forge/ucantone/principal/verifier" - "github.com/fil-forge/ucantone/ucan" - verrs "github.com/fil-forge/ucantone/validator/errors" - "github.com/gobwas/glob" -) - -// FlexibleContext handles both string and []string formats for @context field -// as allowed by the DID Core specification -type FlexibleContext []string - -func (fc *FlexibleContext) UnmarshalJSON(data []byte) error { - // Try array first (most common format) - var arr []string - if err := json.Unmarshal(data, &arr); err == nil { - *fc = FlexibleContext(arr) - return nil - } - - // Fall back to single string format - var str string - if err := json.Unmarshal(data, &str); err == nil { - *fc = FlexibleContext([]string{str}) - return nil - } - - return fmt.Errorf("@context must be string or array of strings") -} - -// Document is a did document that describes a did subject. -// See https://www.w3.org/TR/did-core/#dfn-did-documents. -// Copied from: https://github.com/storacha/indexing-service/blob/fe8f2211a15d851f2672bfeb64dcfc65c52e6011/pkg/server/server.go#L238 -type Document struct { - Context FlexibleContext `json:"@context"` // https://w3id.org/did/v1 - ID string `json:"id"` - Controller []string `json:"controller,omitempty"` - VerificationMethod []VerificationMethod `json:"verificationMethod,omitempty"` - Authentication []string `json:"authentication,omitempty"` - AssertionMethod []string `json:"assertionMethod,omitempty"` -} - -// VerificationMethod describes how to authenticate or authorize interactions -// with a did subject. -// See https://www.w3.org/TR/did-core/#dfn-verification-method. -type VerificationMethod struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Controller string `json:"controller,omitempty"` - PublicKeyMultibase string `json:"publicKeyMultibase,omitempty"` -} - -type HTTPResolver struct { - cfg config -} - -type config struct { - timeout time.Duration - insecure bool - globs map[string]glob.Glob -} - -type Option func(*config) error - -func WithTimeout(timeout time.Duration) Option { - return func(c *config) error { - if timeout == 0 { - return fmt.Errorf("timeout cannot be zero") - } - c.timeout = timeout - return nil - } -} - -func InsecureResolution() Option { - return func(c *config) error { - c.insecure = true - return nil - } -} - -// WithPatterns restricts resolving of did:web's that match the provided glob -// pattern(s). -// -// Note: the pattern should not include the "did:web:" prefix. -func WithPatterns(patterns ...string) Option { - return func(c *config) error { - for _, p := range patterns { - g, err := glob.Compile(p) - if err != nil { - return fmt.Errorf("compiling pattern %q: %w", p, err) - } - if c.globs == nil { - c.globs = map[string]glob.Glob{} - } - c.globs[p] = g - } - return nil - } -} - -// ExtractDomainFromDID extracts the domain from a DID web string -func ExtractDomainFromDID(didWeb did.DID) (string, error) { - // Check if it starts with the required prefix - if didWeb.Method() != "web" { - return "", fmt.Errorf("invalid DID web format: must start with 'did:web:'") - } - - // Extract the domain part - domain := didWeb.Identifier() - - // Check if domain is empty - if domain == "" { - return "", fmt.Errorf("invalid DID web format: no domain specified") - } - - // Validate the domain format - if err := validateDomain(domain); err != nil { - return "", fmt.Errorf("invalid domain '%s': %w", domain, err) - } - - return domain, nil -} - -// validateDomain checks if a string is a valid domain name -func validateDomain(domain string) error { - // Basic length check - if len(domain) > 253 { - return fmt.Errorf("domain too long (max 253 characters)") - } - - // TODO we could do further checking that the domain is valid, length seems fine for now. - - return nil -} - -func WellKnownEndpointFromDID(didWeb did.DID, insecure bool) (url.URL, error) { - domain, err := ExtractDomainFromDID(didWeb) - if err != nil { - return url.URL{}, err - } - - schema := "https" - if insecure { - schema = "http" - } - - endpoint := url.URL{ - Scheme: schema, - Host: domain, - Path: WellKnownDIDPath, - } - - if _, err := url.Parse(endpoint.String()); err != nil { - return url.URL{}, fmt.Errorf("invalid did domain: %w", err) - } - - return endpoint, nil -} - -const WellKnownDIDPath = "/.well-known/did.json" - -func NewHTTPResolver(options ...Option) (*HTTPResolver, error) { - cfg := &config{ - timeout: 10 * time.Second, - insecure: false, - } - for _, opt := range options { - if err := opt(cfg); err != nil { - return nil, err - } - } - // default timeout of 10 seconds, options can override - return &HTTPResolver{cfg: *cfg}, nil -} - -func (r *HTTPResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { - if r.cfg.globs != nil { - match := false - for _, g := range r.cfg.globs { - if match = g.Match(input.Identifier()); match { - break - } - } - if !match { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution via HTTP not permitted")) - } - } - - endpoint, err := WellKnownEndpointFromDID(input, r.cfg.insecure) - if err != nil { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("invalid DID: %w", err)) - } - - ctx, cancel := context.WithTimeout(ctx, r.cfg.timeout) - defer cancel() - didDoc, err := fetchDIDDocument(ctx, endpoint) - if err != nil { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("failed to fetch DID document: %w", err)) - } - if len(didDoc.VerificationMethod) == 0 { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("missing verificationMethod in DID document")) - } - - pubKeyStr := didDoc.VerificationMethod[0].PublicKeyMultibase - if pubKeyStr == "" { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("missing publicKeyMultibase in DID document")) - } - - // TODO: multiple verification methods when https://github.com/fil-forge/ucantone/pull/7 lands - didKey, err := verifier.Parse(fmt.Sprintf("did:key:%s", pubKeyStr)) - if err != nil { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("parsing multibase key: %w", err)) - } - - // token.VerifySignature compares the token's Issuer DID against the - // verifier's DID — if the issuer is did:web:foo and we return an unwrapped - // did:key verifier, that equality check fails and the signature is - // rejected before the bytes are even examined. Wrap so the verifier - // announces the originally-requested DID. - wrapped, err := pverifier.Wrap(didKey, input) - if err != nil { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("wrapping verifier as %s: %w", input, err)) - } - return wrapped, nil -} - -func fetchDIDDocument(ctx context.Context, endpoint url.URL) (*Document, error) { - req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) - if err != nil { - return nil, fmt.Errorf("creating HTTP request: %w", err) - } - - client := &http.Client{} - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("sending HTTP request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } - - var didDoc Document - if err := json.Unmarshal(body, &didDoc); err != nil { - return nil, fmt.Errorf("parsing DID document JSON: %w", err) - } - - return &didDoc, nil -} diff --git a/didresolver/httpresolver_test.go b/didresolver/httpresolver_test.go deleted file mode 100644 index 0abb3a4..0000000 --- a/didresolver/httpresolver_test.go +++ /dev/null @@ -1,593 +0,0 @@ -package didresolver_test - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/fil-forge/libforge/didresolver" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/stretchr/testify/require" -) - -func TestNewHTTPResolver(t *testing.T) { - t.Run("creates resolver with default timeout", func(t *testing.T) { - resolver, err := didresolver.NewHTTPResolver() - require.NoError(t, err) - require.NotNil(t, resolver) - }) - - t.Run("creates resolver with custom timeout", func(t *testing.T) { - resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(5*time.Second), didresolver.InsecureResolution()) - require.NoError(t, err) - require.NotNil(t, resolver) - }) - - t.Run("fails with zero timeout", func(t *testing.T) { - resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(0)) - require.Error(t, err) - require.Contains(t, err.Error(), "timeout cannot be zero") - require.Nil(t, resolver) - }) -} - -func TestHTTPResolver_ResolveDIDKey(t *testing.T) { - testCases := []struct { - name string - setupServer func() *httptest.Server - setupGlobbing func(serverURL string) []string - inputDID string - expectedDIDKey string - expectError bool - errorContains string - }{ - { - name: "successful resolution", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - inputDID: "", // Will be set based on server URL - expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - expectError: false, - }, - { - name: "successful resolution with pattern", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - setupGlobbing: func(serverURL string) []string { - return []string{"*"} - }, - inputDID: "", // Will be set based on server URL - expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - expectError: false, - }, - { - name: "DID resolution not permitted by pattern", - setupServer: func() *httptest.Server { return nil }, - setupGlobbing: func(serverURL string) []string { - return []string{"*.storacha.network"} - }, - inputDID: "did:web:notfound.com", - expectError: true, - errorContains: "resolution via HTTP not permitted", - }, - { - name: "invalid domain when matching against glob", - setupServer: func() *httptest.Server { return nil }, - setupGlobbing: func(serverURL string) []string { - return []string{"*.storacha.network"} - }, - // make too long - inputDID: fmt.Sprintf("did:web:%s.storacha.network", strings.Repeat("a", 254)), - expectError: true, - errorContains: "invalid DID", - }, - { - name: "HTTP error response", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - }, - inputDID: "", // Will be set based on server URL - expectError: true, - errorContains: "unexpected status: 404", - }, - { - name: "invalid JSON response", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("invalid json")) - })) - }, - inputDID: "", // Will be set based on server URL - expectError: true, - errorContains: "parsing DID document JSON", - }, - { - name: "no verification methods", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{}, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - inputDID: "", // Will be set based on server URL - expectError: true, - errorContains: "missing verificationMethod", - }, - { - name: "empty public key", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - inputDID: "", // Will be set based on server URL - expectError: true, - errorContains: "missing publicKeyMultibase", - }, - { - name: "invalid public key format", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "invalid-key", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - inputDID: "", // Will be set based on server URL - expectError: true, - errorContains: "parsing multibase key", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var server *httptest.Server - if tc.setupServer != nil { - server = tc.setupServer() - if server != nil { - defer server.Close() - } - } - - var serverURL string - if server != nil { - serverURL = server.URL - } - var patterns []string - if tc.setupGlobbing != nil { - patterns = tc.setupGlobbing(serverURL) - } - - resolver, err := didresolver.NewHTTPResolver( - didresolver.InsecureResolution(), - didresolver.WithPatterns(patterns...), - ) - require.NoError(t, err) - - // For tests where inputDID is empty, derive it from server URL - var inputDID did.DID - if tc.inputDID == "" && server != nil { - u, _ := url.Parse(serverURL) - inputDID, err = did.Parse("did:web:" + u.Host) - require.NoError(t, err) - } else { - inputDID, err = did.Parse(tc.inputDID) - require.NoError(t, err) - } - - result, unresolvedErr := resolver.Resolve(t.Context(), inputDID) - - if tc.expectError { - require.NotNil(t, unresolvedErr) - require.Contains(t, unresolvedErr.Error(), "unable to resolve") - require.Nil(t, result) - if tc.errorContains != "" { - require.Contains(t, unresolvedErr.Error(), tc.errorContains) - } - } else { - require.Nil(t, unresolvedErr) - // The resolver wraps the underlying did:key verifier so it - // announces the originally-requested DID — required for - // ucantone token.VerifySignature, which compares the token's - // issuer DID against the verifier's DID before checking - // signature bytes. - require.Equal(t, inputDID, result.DID()) - // expectedDIDKey identifies the underlying did:key the - // resolver should have extracted from the document; reach - // through Unwrap() to assert it. - expectedDIDKey, err := did.Parse(tc.expectedDIDKey) - require.NoError(t, err) - unwrapper, ok := result.(interface { - Unwrap() principal.Verifier - }) - require.True(t, ok, "resolver should return a wrapped verifier") - require.Equal(t, expectedDIDKey, unwrapper.Unwrap().DID()) - } - }) - } -} - -func TestHTTPResolver_ResolveDIDKey_Timeout(t *testing.T) { - slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(100 * time.Millisecond) - w.WriteHeader(http.StatusOK) - })) - defer slowServer.Close() - - u, err := url.Parse(slowServer.URL) - require.NoError(t, err) - - didWeb, err := did.Parse("did:web:" + u.Host) - require.NoError(t, err) - - resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(50*time.Millisecond), didresolver.InsecureResolution()) - require.NoError(t, err) - - result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) - require.NotNil(t, unresolvedErr) - require.Contains(t, unresolvedErr.Error(), "unable to resolve") - require.Nil(t, result) -} - -func TestHTTPResolver_ResolveDIDKey_Context(t *testing.T) { - requestReceived := make(chan bool, 1) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case requestReceived <- true: - default: - } - - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - - doc := didresolver.Document{ - Context: []string{"https://w3id.org/did/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - defer server.Close() - - u, err := url.Parse(server.URL) - require.NoError(t, err) - - didWeb, err := did.Parse("did:web:" + u.Host) - require.NoError(t, err) - - resolver, err := didresolver.NewHTTPResolver(didresolver.InsecureResolution()) - require.NoError(t, err) - - result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, unresolvedErr) - require.NotEqual(t, did.Undef, result) - - select { - case <-requestReceived: - case <-time.After(time.Second): - t.Fatal("request was not received by server") - } -} - -func TestFlexibleContext_UnmarshalJSON(t *testing.T) { - testCases := []struct { - name string - input string - expectedValue didresolver.FlexibleContext - expectError bool - errorContains string - }{ - { - name: "single string context", - input: `"https://w3id.org/did/v1"`, - expectedValue: didresolver.FlexibleContext{"https://w3id.org/did/v1"}, - expectError: false, - }, - { - name: "array of strings context", - input: `["https://w3id.org/did/v1", "https://w3id.org/security/v1"]`, - expectedValue: didresolver.FlexibleContext{"https://w3id.org/did/v1", "https://w3id.org/security/v1"}, - expectError: false, - }, - { - name: "empty array context", - input: `[]`, - expectedValue: didresolver.FlexibleContext{}, - expectError: false, - }, - { - name: "invalid type - number", - input: `123`, - expectError: true, - errorContains: "@context must be string or array of strings", - }, - { - name: "invalid type - object", - input: `{"foo": "bar"}`, - expectError: true, - errorContains: "@context must be string or array of strings", - }, - { - name: "invalid type - boolean", - input: `true`, - expectError: true, - errorContains: "@context must be string or array of strings", - }, - { - name: "array with non-string elements", - input: `["https://w3id.org/did/v1", 123]`, - expectError: true, - errorContains: "@context must be string or array of strings", - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - var fc didresolver.FlexibleContext - err := json.Unmarshal([]byte(tc.input), &fc) - - if tc.expectError { - require.Error(t, err) - require.Contains(t, err.Error(), tc.errorContains) - } else { - require.NoError(t, err) - require.Equal(t, tc.expectedValue, fc) - } - }) - } -} - -func TestHTTPResolver_ResolveDIDKey_ContextFormats(t *testing.T) { - testCases := []struct { - name string - setupServer func() *httptest.Server - expectedDIDKey string - }{ - { - name: "DID document with string context", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - // Using raw JSON to ensure we send a string context, not array - docJSON := `{ - "@context": "https://w3id.org/did/v1", - "id": "did:web:example.com", - "verificationMethod": [{ - "id": "did:web:example.com#key1", - "type": "Ed25519VerificationKey2018", - "controller": "did:web:example.com", - "publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" - }] - }` - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(docJSON)) - })) - }, - expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - { - name: "DID document with array context", - setupServer: func() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != didresolver.WellKnownDIDPath { - w.WriteHeader(http.StatusNotFound) - return - } - doc := didresolver.Document{ - Context: didresolver.FlexibleContext{"https://w3id.org/did/v1", "https://w3id.org/security/v1"}, - ID: "did:web:example.com", - VerificationMethod: []didresolver.VerificationMethod{ - { - ID: "did:web:example.com#key1", - Type: "Ed25519VerificationKey2018", - Controller: "did:web:example.com", - PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(doc) - })) - }, - expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - server := tc.setupServer() - defer server.Close() - - u, err := url.Parse(server.URL) - require.NoError(t, err) - - didWeb, err := did.Parse("did:web:" + u.Host) - require.NoError(t, err) - - resolver, err := didresolver.NewHTTPResolver(didresolver.InsecureResolution()) - require.NoError(t, err) - - result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) - require.Nil(t, unresolvedErr) - - // Resolver wraps the underlying did:key as the requested did:web — - // see ucantone/ucan/token/token.go for why this matters. - require.Equal(t, didWeb, result.DID()) - expectedDIDKey, err := did.Parse(tc.expectedDIDKey) - require.NoError(t, err) - unwrapper, ok := result.(interface { - Unwrap() principal.Verifier - }) - require.True(t, ok, "resolver should return a wrapped verifier") - require.Equal(t, expectedDIDKey, unwrapper.Unwrap().DID()) - }) - } -} - -func TestExtractDomainFromDID(t *testing.T) { - testCases := []struct { - name string - did string - expectedDomain string - expectError bool - errorContains string - }{ - { - name: "valid did:web", - did: "did:web:example.com", - expectedDomain: "example.com", - expectError: false, - }, - { - name: "valid did:web with subdomain", - did: "did:web:api.example.com", - expectedDomain: "api.example.com", - expectError: false, - }, - { - name: "invalid prefix", - did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - expectError: true, - errorContains: "invalid DID web format: must start with 'did:web:'", - }, - { - name: "empty domain", - did: "did:web:", - expectError: true, - errorContains: "invalid DID web format: no domain specified", - }, - { - name: "domain too long", - did: "did:web:" + strings.Repeat("a", 254), - expectError: true, - errorContains: "domain too long", - }, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - did, err := did.Parse(tc.did) - require.NoError(t, err) - - domain, err := didresolver.ExtractDomainFromDID(did) - - if tc.expectError { - require.Error(t, err) - require.Contains(t, err.Error(), tc.errorContains) - } else { - require.NoError(t, err) - require.Equal(t, tc.expectedDomain, domain) - } - }) - } -} diff --git a/didresolver/mapresolver.go b/didresolver/mapresolver.go deleted file mode 100644 index 6adffae..0000000 --- a/didresolver/mapresolver.go +++ /dev/null @@ -1,57 +0,0 @@ -package didresolver - -import ( - "context" - "fmt" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" - pverifier "github.com/fil-forge/ucantone/principal/verifier" - "github.com/fil-forge/ucantone/ucan" - verrs "github.com/fil-forge/ucantone/validator/errors" -) - -type MapResolver struct { - Mapping map[did.DID]ucan.Verifier -} - -func (r *MapResolver) Resolve(_ context.Context, input did.DID) (ucan.Verifier, error) { - // ctx is unused; this implementation only looks in a local mapping. - dk, ok := r.Mapping[input] - if !ok { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not found in mapping: %s", input)) - } - return dk, nil -} - -// NewMapResolver creates a new MapResolver from a mapping of DID string to -// verifier string. -func NewMapResolver(smap map[string]string) (*MapResolver, error) { - dmap := map[did.DID]ucan.Verifier{} - for k, v := range smap { - dk, err := did.Parse(k) - if err != nil { - return nil, err - } - // TODO: multiple verification methods when https://github.com/fil-forge/ucantone/pull/7 lands - didKey, err := verifier.Parse(v) - if err != nil { - return nil, err - } - // token.VerifySignature compares the token's Issuer DID against the - // verifier's DID. If a did:web (or any non-key DID) maps to a did:key - // verifier, the equality check fails and signature verification is - // rejected before the bytes are even examined. Wrap the verifier so - // it announces the requested DID. did:key inputs are stored unwrapped. - var dv ucan.Verifier = didKey - if dk.Method() != "key" { - wrapped, err := pverifier.Wrap(didKey, dk) - if err != nil { - return nil, fmt.Errorf("wrapping verifier as %s: %w", dk, err) - } - dv = wrapped - } - dmap[dk] = dv - } - return &MapResolver{Mapping: dmap}, nil -} diff --git a/didresolver/mapresolver_test.go b/didresolver/mapresolver_test.go deleted file mode 100644 index 5fa39d7..0000000 --- a/didresolver/mapresolver_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package didresolver_test - -import ( - "testing" - - "github.com/fil-forge/libforge/didresolver" - "github.com/fil-forge/ucantone/did" - "github.com/stretchr/testify/require" -) - -func TestPrincipalResolver(t *testing.T) { - p0, err := did.Parse("did:web:example.com") - require.NoError(t, err) - r, err := did.Parse("did:key:z6MkghfetkhrBZwUupJrv8MmYDH1JhKCQCGj1trbaZPA3dAd") - require.NoError(t, err) - p1, err := did.Parse("did:web:example.org") - require.NoError(t, err) - - pm := map[string]string{p0.String(): r.String()} - ppr, err := didresolver.NewMapResolver(pm) - require.NoError(t, err) - - resolved, err := ppr.Resolve(t.Context(), p0) - require.NoError(t, err) - // Resolver wraps the underlying did:key verifier so it announces the - // requested did:web — required for ucantone token.VerifySignature, which - // compares issuer DID against verifier DID before checking signature bytes. - require.Equal(t, p0, resolved.DID()) - - // cannot resolve DID not in mapping - _, err = ppr.Resolve(t.Context(), p1) - require.ErrorContains(t, err, "not found in mapping") -} diff --git a/didresolver/self.go b/didresolver/self.go deleted file mode 100644 index aa98fa9..0000000 --- a/didresolver/self.go +++ /dev/null @@ -1,32 +0,0 @@ -package didresolver - -import ( - "context" - "fmt" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/ucan" -) - -type SelfResolver struct { - self did.DID - verifier ucan.Verifier -} - -func (r *SelfResolver) Resolve(_ context.Context, input did.DID) (ucan.Verifier, error) { - if input != r.self { - return nil, fmt.Errorf("not the service's own DID") - } - return r.verifier, nil -} - -// NewSelfResolver returns a DID resolver tier that satisfies requests for the -// service's own DID using the in-memory identity. Returns an error for any -// other DID so a [TieredResolver] falls through to the next tier. -func NewSelfResolver(id principal.Signer) *SelfResolver { - return &SelfResolver{ - self: id.DID(), - verifier: id.Verifier(), - } -} diff --git a/didresolver/self_test.go b/didresolver/self_test.go deleted file mode 100644 index 495bbb0..0000000 --- a/didresolver/self_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package didresolver_test - -import ( - "testing" - - "github.com/fil-forge/libforge/didresolver" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/signer" - "github.com/stretchr/testify/require" -) - -func TestSelfResolver(t *testing.T) { - // A service identified by a did:web DID backed by an ed25519 key. Wrapping - // the did:key signer makes it announce the did:web DID without changing how - // it signs. - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - key, err := ed25519.Generate() - require.NoError(t, err) - - self, err := signer.Wrap(key, didWeb) - require.NoError(t, err) - require.Equal(t, didWeb, self.DID()) - - resolver := didresolver.NewSelfResolver(self) - - t.Run("resolves the service's own did:web without an HTTP request", func(t *testing.T) { - verifier, err := resolver.Resolve(t.Context(), didWeb) - require.NoError(t, err) - - // The resolved verifier announces the requested did:web — not the - // underlying did:key — so token.VerifySignature's issuer-vs-verifier DID - // equality check passes. - require.Equal(t, didWeb, verifier.DID()) - - // It is the service's real verifier: signatures from the signer verify. - msg := []byte("hello") - require.True(t, verifier.Verify(msg, self.Sign(msg))) - }) - - t.Run("does not resolve a different DID so a TieredResolver falls through", func(t *testing.T) { - other, err := did.Parse("did:web:example.org") - require.NoError(t, err) - - verifier, err := resolver.Resolve(t.Context(), other) - require.Error(t, err) - require.Nil(t, verifier) - require.ErrorContains(t, err, "not the service's own DID") - }) - - t.Run("does not resolve the underlying did:key", func(t *testing.T) { - // Only the wrapped did:web identity is served; the did:key the signer - // wraps is a different DID and must not resolve. - verifier, err := resolver.Resolve(t.Context(), key.DID()) - require.Error(t, err) - require.Nil(t, verifier) - require.ErrorContains(t, err, "not the service's own DID") - }) -} diff --git a/didresolver/tieredresolver.go b/didresolver/tieredresolver.go deleted file mode 100644 index c9e1ea2..0000000 --- a/didresolver/tieredresolver.go +++ /dev/null @@ -1,38 +0,0 @@ -package didresolver - -import ( - "context" - "errors" - "fmt" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/ucan" - verrs "github.com/fil-forge/ucantone/validator/errors" -) - -// FIXME: remove when https://github.com/fil-forge/ucantone/pull/7 lands -type DIDVerifierResolverFunc func(ctx context.Context, did did.DID) (ucan.Verifier, error) - -type TieredResolver struct { - Tiers []DIDVerifierResolverFunc -} - -func (r *TieredResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { - if len(r.Tiers) == 0 { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("no resolvers configured")) - } - var errs error - for _, tier := range r.Tiers { - verifier, err := tier(ctx, input) - if err != nil { - errs = errors.Join(errs, err) - continue - } - return verifier, nil - } - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not resolvable by any resolver: %w", errs)) -} - -func NewTieredResolver(tiers ...DIDVerifierResolverFunc) *TieredResolver { - return &TieredResolver{Tiers: tiers} -} diff --git a/didresolver/tieredresolver_test.go b/didresolver/tieredresolver_test.go deleted file mode 100644 index 559826c..0000000 --- a/didresolver/tieredresolver_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package didresolver_test - -import ( - "context" - "fmt" - "testing" - - "github.com/fil-forge/libforge/didresolver" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" - "github.com/fil-forge/ucantone/ucan" - "github.com/stretchr/testify/require" -) - -func TestTieredResolver_ResolveDIDKey(t *testing.T) { - didWeb, err := did.Parse("did:web:example.com") - require.NoError(t, err) - - didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - - t.Run("returns from the first tier when it resolves", func(t *testing.T) { - tier1 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return didKey, nil - }, - } - tier2 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - t.Fatal("second tier should not be called when first tier resolves") - return nil, nil - }, - } - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, - } - - result, err := resolver.Resolve(t.Context(), didWeb) - require.NoError(t, err) - require.Equal(t, didKey, result) - require.Equal(t, 1, tier1.getCallCount()) - require.Equal(t, 0, tier2.getCallCount()) - }) - - t.Run("falls through to later tier when earlier tiers fail", func(t *testing.T) { - tier1 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return nil, fmt.Errorf("tier1 failed") - }, - } - tier2 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return nil, fmt.Errorf("tier2 failed") - }, - } - tier3 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return didKey, nil - }, - } - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{ - tier1.ResolveDIDKey, - tier2.ResolveDIDKey, - tier3.ResolveDIDKey, - }, - } - - result, err := resolver.Resolve(t.Context(), didWeb) - require.NoError(t, err) - require.Equal(t, didKey, result) - require.Equal(t, 1, tier1.getCallCount()) - require.Equal(t, 1, tier2.getCallCount()) - require.Equal(t, 1, tier3.getCallCount()) - }) - - t.Run("returns joined error when all tiers fail", func(t *testing.T) { - tier1 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return nil, fmt.Errorf("tier1 specific error") - }, - } - tier2 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return nil, fmt.Errorf("tier2 specific error") - }, - } - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, - } - - result, err := resolver.Resolve(t.Context(), didWeb) - require.Error(t, err) - require.Nil(t, result) - require.Contains(t, err.Error(), "unable to resolve") - require.Contains(t, err.Error(), "not resolvable by any resolver") - require.Contains(t, err.Error(), "tier1 specific error") - require.Contains(t, err.Error(), "tier2 specific error") - require.Equal(t, 1, tier1.getCallCount()) - require.Equal(t, 1, tier2.getCallCount()) - }) - - t.Run("returns error with no tiers configured", func(t *testing.T) { - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{}, - } - - result, err := resolver.Resolve(t.Context(), didWeb) - require.Error(t, err) - require.Nil(t, result) - require.Contains(t, err.Error(), "unable to resolve") - require.Contains(t, err.Error(), "no resolvers configured") - }) - - t.Run("works with a single tier", func(t *testing.T) { - tier1 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - return didKey, nil - }, - } - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, - } - - result, err := resolver.Resolve(t.Context(), didWeb) - require.NoError(t, err) - require.Equal(t, didKey, result) - require.Equal(t, 1, tier1.getCallCount()) - }) - - t.Run("composes with MapResolver tiers", func(t *testing.T) { - didA, err := did.Parse("did:web:alice.example.com") - require.NoError(t, err) - didB, err := did.Parse("did:web:bob.example.com") - require.NoError(t, err) - didC, err := did.Parse("did:web:carol.example.com") - require.NoError(t, err) - - keyA, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - require.NoError(t, err) - keyB, err := verifier.Parse("did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6") - require.NoError(t, err) - - mapA, err := didresolver.NewMapResolver(map[string]string{didA.String(): keyA.DID().String()}) - require.NoError(t, err) - mapB, err := didresolver.NewMapResolver(map[string]string{didB.String(): keyB.DID().String()}) - require.NoError(t, err) - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{mapA.Resolve, mapB.Resolve}, - } - - // Resolves via the first tier. MapResolver wraps the did:key verifier - // as the requested did:web so token.VerifySignature's issuer-vs-verifier - // DID equality check passes — see ucantone/ucan/token/token.go. - resA, err := resolver.Resolve(t.Context(), didA) - require.NoError(t, err) - require.Equal(t, didA, resA.DID()) - - // Falls through to the second tier - resB, err := resolver.Resolve(t.Context(), didB) - require.NoError(t, err) - require.Equal(t, didB, resB.DID()) - - // Not resolvable by any tier - _, err = resolver.Resolve(t.Context(), didC) - require.Error(t, err) - require.Contains(t, err.Error(), "not resolvable by any resolver") - }) - - t.Run("propagates context to tiers", func(t *testing.T) { - type ctxKey string - key := ctxKey("marker") - ctx := context.WithValue(t.Context(), key, "value") - - var seen string - tier1 := &mockResolver{ - resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { - if v, ok := ctx.Value(key).(string); ok { - seen = v - } - return didKey, nil - }, - } - - resolver := &didresolver.TieredResolver{ - Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, - } - - _, err := resolver.Resolve(ctx, didWeb) - require.NoError(t, err) - require.Equal(t, "value", seen) - }) -} diff --git a/go.mod b/go.mod index e849221..221c3cd 100644 --- a/go.mod +++ b/go.mod @@ -3,42 +3,41 @@ module github.com/fil-forge/libforge go 1.25.3 require ( - github.com/alanshaw/dag-json-gen v0.0.5 + github.com/alanshaw/dag-json-gen v0.0.6 github.com/fil-forge/automobile v0.0.1 github.com/fil-forge/ucantone v0.0.0-20260521210642-84d8c533075b github.com/filecoin-project/go-data-segment v0.0.1 github.com/filecoin-project/go-fil-commcid v0.3.1 github.com/filecoin-project/go-fil-commp-hashhash v0.2.0 - github.com/gobwas/glob v0.2.3 github.com/ipfs/go-cid v0.6.1 github.com/ipfs/go-log/v2 v2.9.1 github.com/multiformats/go-multibase v0.3.0 github.com/multiformats/go-multicodec v0.10.0 github.com/multiformats/go-multihash v0.2.3 github.com/multiformats/go-varint v0.1.0 - github.com/patrickmn/go-cache v2.1.0+incompatible github.com/stretchr/testify v1.11.1 github.com/whyrusleeping/cbor-gen v0.3.1 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/filecoin-project/go-state-types v0.18.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap v1.28.0 // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/exp v0.0.0-20230418202329-0354be287a23 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/sys v0.43.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index e740baf..93488a1 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -github.com/alanshaw/dag-json-gen v0.0.5 h1:jUOqsTrfZ7ddkBqAsx/xbCeJtpe70jrFL2Z+i4qQB1U= -github.com/alanshaw/dag-json-gen v0.0.5/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= -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= +github.com/alanshaw/dag-json-gen v0.0.6 h1:MiscvWVOhs6/ux7OUdPz2nDRA7GwklZyaAy4XWexpr0= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7GfobLhA= github.com/fil-forge/automobile v0.0.1/go.mod h1:TsO7jlO8ykJZY5tF8j4GsUcu3F02lEzxO7ULoB61hRA= github.com/fil-forge/ucantone v0.0.0-20260521210642-84d8c533075b h1:ILG7dtSWiOO/fYiesqYj8Esm1qeIxFy7qkZoFdnjPpU= @@ -46,10 +44,7 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -66,12 +61,11 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20230418202329-0354be287a23 h1:4NKENAGIctmZYLK9W+X1kDK8ObBFqOSCJM6WE7CvkJY= -golang.org/x/exp v0.0.0-20230418202329-0354be287a23/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 0000000..d326f2d --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,121 @@ +package identity + +import ( + "fmt" + "os" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/verification/multikey" + "github.com/fil-forge/ucantone/verification/multikey/ed25519" +) + +// Identity holds a service's cryptographic identity. It's intended to be held +// by the service itself. This is the source of its DID document, can then be +// published (eg, to the web). Other services should use normal DID resolution +// to find the document. +type Identity struct { + multikey.Issuer +} + +// New creates a new identity. If privateKeyBase64 is empty, generates a new +// key. If serviceDID is empty, uses the key DID derived from the key. +func New(privateKeyBase64 string, serviceDID string) (Identity, error) { + var signer multikey.Signer + var issuer multikey.Issuer + var err error + + if privateKeyBase64 == "" { + // Generate ephemeral identity + signer, err = ed25519.Generate() + if err != nil { + return Identity{}, fmt.Errorf("failed to generate signer: %w", err) + } + } else { + // Decode provided key + signer, err = ed25519.Parse(privateKeyBase64) + if err != nil { + return Identity{}, fmt.Errorf("failed to create signer from key: %w", err) + } + } + + if serviceDID == "" { + issuer = multikey.KeyIssuer(signer) + } else { + d, err := did.Parse(serviceDID) + if err != nil { + return Identity{}, fmt.Errorf("failed to parse service DID %q: %w", serviceDID, err) + } + issuer = multikey.NewIssuer(d, signer) + } + + return Identity{Issuer: issuer}, nil +} + +// DIDDocument returns the identity's DID document. This should be available for +// other services performing did:web resolution. This enables other services to +// verify signatures from this service. +func (i Identity) DIDDocument() (did.Document, error) { + doc := did.NewDocument(i.DID()) + + // Can only derive a verification method from a multikey verifier. This could + // be extended in the future. + mkVerifier, ok := i.Verifier().(multikey.Verifier) + if !ok { + return did.Document{}, fmt.Errorf("identity does not have a multikey verifier") + } + vm := multikey.DeriveVerificationMethod(doc.Fragment("#key-0"), mkVerifier) + + if err := doc.VerificationMethods.Add(vm); err != nil { + return did.Document{}, err + } + + if err := doc.Authentication.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.AssertionMethod.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.CapabilityDelegation.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.CapabilityInvocation.Add(vm); err != nil { + return did.Document{}, err + } + + return doc, nil +} + +// NewFromPEMFile creates a new identity from an Ed25519 PEM key file. +func NewFromPEMFile(keyFilePath string) (Identity, error) { + pem, err := os.ReadFile(keyFilePath) + if err != nil { + return Identity{}, fmt.Errorf("failed to read key file: %w", err) + } + keySigner, err := DecodeSignerFromPEM(pem) + if err != nil { + return Identity{}, fmt.Errorf("failed to decode key from PEM file: %w", err) + } + return Identity{Issuer: multikey.KeyIssuer(keySigner)}, nil +} + +// NewFromPEMFileWithDID creates a new identity from an Ed25519 PEM key file. +// When serviceDID is provided (e.g., "did:web:upload"), the identity will use +// that DID. Otherwise, it will use the key DID derived from the key. +func NewFromPEMFileWithDID(keyFilePath string, serviceDID string) (Identity, error) { + keyId, err := NewFromPEMFile(keyFilePath) + if err != nil { + return Identity{}, fmt.Errorf("creating identity from PEM file: %w", err) + } + + // If serviceDID is provided, wrap the signer with the did:web identity + if serviceDID != "" { + d, err := did.Parse(serviceDID) + if err != nil { + return Identity{}, fmt.Errorf("failed to parse service DID %q: %w", serviceDID, err) + } + + return Identity{Issuer: multikey.NewIssuer(d, keyId)}, nil + } + + return Identity{Issuer: keyId.Issuer}, nil +} diff --git a/identity/pem.go b/identity/pem.go index 7d3aeb8..57f2325 100644 --- a/identity/pem.go +++ b/identity/pem.go @@ -7,15 +7,16 @@ import ( "encoding/pem" "fmt" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/verification/multikey" + "github.com/fil-forge/ucantone/verification/multikey/ed25519" ) -// EncodeEd25519SignerToPEM encodes an Ed25519 signer to a PKCS#8 PEM format. -func EncodeEd25519SignerToPEM(signer ed25519.Signer) ([]byte, error) { - privateKey := crypto_ed25519.PrivateKey(signer.Raw()) - privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) +// EncodeSignerToPEM encodes a signer to a PKCS#8 PEM format. The signer's key +// should be of a type supported by ["crypto/x509".MarshalPKCS8PrivateKey]. +func EncodeSignerToPEM(signer multikey.Signer) ([]byte, error) { + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(signer.PrivateKey()) if err != nil { - return nil, fmt.Errorf("marshaling ed25519 private key: %w", err) + return nil, fmt.Errorf("marshaling private key of signer %s: %w", signer, err) } privateKeyBlock := &pem.Block{ @@ -25,14 +26,15 @@ func EncodeEd25519SignerToPEM(signer ed25519.Signer) ([]byte, error) { buffer := new(bytes.Buffer) if err := pem.Encode(buffer, privateKeyBlock); err != nil { - return nil, fmt.Errorf("encoding ed25519 private key: %w", err) + return nil, fmt.Errorf("encoding private key of signer %s: %w", signer, err) } return buffer.Bytes(), nil } -// DecodeEd25519SignerFromPEM loads an Ed25519 private key from a PKCS#8 PEM. -func DecodeEd25519SignerFromPEM(pemData []byte) (ed25519.Signer, error) { +// DecodeSignerFromPEM loads a private key from a PKCS#8 PEM as a signer. +// Currently, only Ed25519 keys are supported. +func DecodeSignerFromPEM(pemData []byte) (multikey.Signer, error) { var privateKey *crypto_ed25519.PrivateKey rest := pemData for { diff --git a/identity/pem_test.go b/identity/pem_test.go index 8ff61ba..b824fd5 100644 --- a/identity/pem_test.go +++ b/identity/pem_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/fil-forge/libforge/identity" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/verification/multikey/ed25519" "github.com/stretchr/testify/require" ) @@ -12,25 +12,25 @@ func TestEd25519SignerPEMRoundTrip(t *testing.T) { original, err := ed25519.Generate() require.NoError(t, err) - pemBytes, err := identity.EncodeEd25519SignerToPEM(original) + pemBytes, err := identity.EncodeSignerToPEM(original) require.NoError(t, err) require.NotEmpty(t, pemBytes) - decoded, err := identity.DecodeEd25519SignerFromPEM(pemBytes) + decoded, err := identity.DecodeSignerFromPEM(pemBytes) require.NoError(t, err) require.Equal(t, original.Raw(), decoded.Raw()) require.Equal(t, original.Bytes(), decoded.Bytes()) - require.Equal(t, original.DID(), decoded.DID()) + require.Equal(t, original.KeyDID(), decoded.KeyDID()) } func TestDecodeEd25519SignerFromPEM_NoPrivateKeyBlock(t *testing.T) { pemData := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n") - _, err := identity.DecodeEd25519SignerFromPEM(pemData) + _, err := identity.DecodeSignerFromPEM(pemData) require.ErrorContains(t, err, "no PRIVATE KEY block found") } func TestDecodeEd25519SignerFromPEM_Empty(t *testing.T) { - _, err := identity.DecodeEd25519SignerFromPEM(nil) + _, err := identity.DecodeSignerFromPEM(nil) require.ErrorContains(t, err, "no PRIVATE KEY block found") } diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 6be397e..4d7a47d 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -4,28 +4,35 @@ import ( "net/url" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/signer" + "github.com/fil-forge/ucantone/verification/multikey" + "github.com/fil-forge/ucantone/verification/multikey/ed25519" ) -// did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi -var Alice, _ = ed25519.Parse("MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFnQ==") +var ( + // did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi + alice, _ = ed25519.Parse("MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFnQ==") + Alice = multikey.KeyIssuer(alice) -// did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob -var Bob, _ = ed25519.Parse("MgCYbj5AJfVvdrjkjNCxB3iAUwx7RQHVQ7H1sKyHy46IosQ==") + // did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob + bob, _ = ed25519.Parse("MgCYbj5AJfVvdrjkjNCxB3iAUwx7RQHVQ7H1sKyHy46IosQ==") + Bob = multikey.KeyIssuer(bob) -// did:key:z6MkwYkD48SUrPhQ5Sf8qk5L8FW2L32Ze4guLnZXY4DrDCAR -var Carol, _ = ed25519.Parse("MgCa5pEVgZbqGILBFD3/TAd1a1OOJMuPsVz/uxS9ceU5jeg==") + // did:key:z6MkwYkD48SUrPhQ5Sf8qk5L8FW2L32Ze4guLnZXY4DrDCAR + carol, _ = ed25519.Parse("MgCa5pEVgZbqGILBFD3/TAd1a1OOJMuPsVz/uxS9ceU5jeg==") + Carol = multikey.KeyIssuer(carol) -// did:key:z6MktafZTREjJkvV5mfJxcLpNBoVPwDLhTuMg9ng7dY4zMAL -var Mallory, _ = ed25519.Parse("MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkA==") + // did:key:z6MktafZTREjJkvV5mfJxcLpNBoVPwDLhTuMg9ng7dY4zMAL + mallory, _ = ed25519.Parse("MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkA==") + Mallory = multikey.KeyIssuer(mallory) -// did:key:z6Mkk3mDiu74xxyYEff5X1p568fVqEMczj5keYPT8qVMNsVC -var Service, _ = ed25519.Parse("MgCZyxtpD6SFBcXCXUKPTkLrc2+RlmaBjL/tMgWCT3+MUlw==") + // did:key:z6Mkk3mDiu74xxyYEff5X1p568fVqEMczj5keYPT8qVMNsVC + service, _ = ed25519.Parse("MgCZyxtpD6SFBcXCXUKPTkLrc2+RlmaBjL/tMgWCT3+MUlw==") + Service = multikey.KeyIssuer(service) -var webServiceDID, _ = did.Parse("did:web:test.storacha.network") + // did:web:test.storacha.network + webServiceDID, _ = did.Parse("did:web:test.storacha.network") + WebService = multikey.NewIssuer(webServiceDID, service) + WebServiceSigner = service -// did:web:test.storacha.network -var WebService, _ = signer.Wrap(Service, webServiceDID) - -var TestURL, _ = url.Parse("https://test.storacha.network") + TestURL, _ = url.Parse("https://test.storacha.network") +) diff --git a/testutil/gen.go b/testutil/gen.go index 79ef04d..21ca60e 100644 --- a/testutil/gen.go +++ b/testutil/gen.go @@ -1,34 +1,19 @@ package testutil import ( - crand "crypto/rand" - "testing" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/ipfs/go-cid" - mh "github.com/multiformats/go-multihash" + "github.com/fil-forge/ucantone/testutil" ) -func RandomBytes(t *testing.T, size int) []byte { - bytes := make([]byte, size) - _, _ = crand.Read(bytes) - return bytes -} - -func RandomCID(t *testing.T) cid.Cid { - return cid.NewCidV1(cid.Raw, RandomMultihash(t)) -} - -func RandomDID(t *testing.T) did.DID { - return RandomSigner(t).DID() -} +var ( + RandomBytes = testutil.RandomBytes + RandomCID = testutil.RandomCID + RandomDigest = testutil.RandomDigest + RandomDID = testutil.RandomDID + RandomSigner = testutil.RandomSigner + RandomMultikeyIssuer = testutil.RandomMultikeyIssuer + RandomIssuer = testutil.RandomIssuer + RandomPrincipal = testutil.RandomPrincipal -func RandomMultihash(t *testing.T) mh.Multihash { - bytes := RandomBytes(t, 10) - return Must(mh.Sum(bytes, mh.SHA2_256, -1))(t) -} - -func RandomSigner(t *testing.T) ed25519.Signer { - return Must(ed25519.Generate())(t) -} + // Deprecated alias for RandomDigest, which is a more accurate name. + RandomMultihash = testutil.RandomDigest +) diff --git a/ucan/attestations.go b/ucan/attestations.go deleted file mode 100644 index 8eb4665..0000000 --- a/ucan/attestations.go +++ /dev/null @@ -1,58 +0,0 @@ -package ucanlib - -import ( - "bytes" - "context" - "fmt" - "iter" - - "github.com/fil-forge/libforge/commands/ucan/attest" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/ucan" - "github.com/fil-forge/ucantone/varsig/algorithm/nonstandard" -) - -// InvocationListerFunc lists invocations that match EXACTLY the given audience, -// command, and subject. -type InvocationListerFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] - -// ProofAttestations returns a list of attestations for proofs that need them. -// i.e. if a proof is signed with a non-standard signature this function will -// fetch an attestation for it, and fail if it cannot. The authority parameter -// is the DID of the service we trust to be issuing attestations. -func ProofAttestations(ctx context.Context, listInvocations InvocationListerFunc, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) { - var attestations []ucan.Invocation - for _, proof := range proofs { - if proof.Signature().Header().SignatureAlgorithm().Code() != nonstandard.Code { - continue - } - var attestation ucan.Invocation - for inv, err := range listInvocations(ctx, proof.Audience(), attest.Proof.Command, authority) { - if err != nil { - return nil, fmt.Errorf("listing invocations for proof signed by %q: %w", proof.Issuer(), err) - } - // unlikely since all attestations should be self-signed by the authority - if inv.Issuer() != authority { - continue - } - if ucan.IsExpired(inv) { - continue - } - // ensure this attestation corresponds to the proof - var proofArgs attest.ProofArguments - if err := proofArgs.UnmarshalCBOR(bytes.NewReader(inv.ArgumentsBytes())); err != nil { - continue - } - if proofArgs.Proof != proof.Link() { - continue - } - attestation = inv - break - } - if attestation == nil { - return nil, fmt.Errorf("no attestation found for proof signed by %q", proof.Issuer()) - } - attestations = append(attestations, attestation) - } - return attestations, nil -} diff --git a/ucan/attestations_test.go b/ucan/attestations_test.go deleted file mode 100644 index cec975b..0000000 --- a/ucan/attestations_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package ucanlib_test - -import ( - "context" - "errors" - "iter" - "testing" - - "github.com/fil-forge/libforge/commands/ucan/attest" - "github.com/fil-forge/libforge/didmailto" - "github.com/fil-forge/libforge/testutil" - ucanlib "github.com/fil-forge/libforge/ucan" - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/absentee" - "github.com/fil-forge/ucantone/ucan" - "github.com/fil-forge/ucantone/ucan/command" - "github.com/fil-forge/ucantone/ucan/delegation" - "github.com/fil-forge/ucantone/ucan/invocation" - "github.com/ipfs/go-cid" - "github.com/stretchr/testify/require" -) - -// recordedCall captures arguments passed to a stub AttestationGetterFunc. -type recordedCall struct { - aud did.DID - cmd ucan.Command - sub did.DID -} - -// stubAttestationLister returns an AttestationGetterFunc that produces a fresh -// attestation invocation per call (signed by authority) and records each call. -func stubAttestationLister(authority ucan.Signer, proofs []cid.Cid, calls *[]recordedCall) ucanlib.InvocationListerFunc { - i := 0 - return func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { - *calls = append(*calls, recordedCall{aud: aud, cmd: cmd, sub: sub}) - return func(yield func(ucan.Invocation, error) bool) { - if i >= len(proofs) { - return - } - inv, err := attest.Proof.Invoke( - authority, - sub, - &attest.ProofArguments{Proof: proofs[i]}, - invocation.WithAudience(aud), - ) - if err != nil { - yield(nil, err) - return - } - yield(inv, nil) - i++ - } - } -} - -func TestProofAttestations(t *testing.T) { - t.Run("no proofs", func(t *testing.T) { - service := testutil.WebService - var calls []recordedCall - lister := stubAttestationLister(service, nil, &calls) - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, nil, service.DID()) - require.NoError(t, err) - require.Empty(t, attestations) - require.Empty(t, calls) - }) - - t.Run("standard signatures only", func(t *testing.T) { - service := testutil.WebService - space := testutil.RandomSigner(t) - alice := testutil.Alice - cmd := testutil.Must(command.Parse("/test/do"))(t) - - // ed25519-signed proof — should be filtered out (no attestation needed). - dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) - - var calls []recordedCall - lister := stubAttestationLister(service, nil, &calls) - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) - require.NoError(t, err) - require.Empty(t, attestations) - require.Empty(t, calls, "lister should not be called for standard signatures") - }) - - t.Run("absentee-signed proof", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - // account (absentee, did:mailto) → agent — this proof needs an attestation. - dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - var calls []recordedCall - lister := stubAttestationLister(service, []cid.Cid{dlg.Link()}, &calls) - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) - require.NoError(t, err) - require.Len(t, attestations, 1) - require.Len(t, calls, 1) - - // Lister should be called with the proof's audience, the /ucan/attest/proof - // command, and the authority as subject. - require.Equal(t, agent.DID(), calls[0].aud) - require.Equal(t, attest.Proof.Command, calls[0].cmd) - require.Equal(t, service.DID(), calls[0].sub) - }) - - t.Run("mixed standard and absentee proofs", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - bob := testutil.Bob - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - // standard signature — no attestation needed - standardDlg := testutil.Must(delegation.Delegate(space, bob.DID(), space.DID(), cmd))(t) - // absentee signature — needs attestation - absenteeDlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - var calls []recordedCall - lister := stubAttestationLister(service, []cid.Cid{absenteeDlg.Link()}, &calls) - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{standardDlg, absenteeDlg}, service.DID()) - require.NoError(t, err) - require.Len(t, attestations, 1, "only the absentee-signed proof needs an attestation") - require.Len(t, calls, 1) - require.Equal(t, agent.DID(), calls[0].aud) - }) - - t.Run("multiple absentee-signed proofs", func(t *testing.T) { - service := testutil.WebService - aliceMailto := testutil.Must(didmailto.New("alice@example.com"))(t) - bobMailto := testutil.Must(didmailto.New("bob@example.com"))(t) - aliceAccount := absentee.From(aliceMailto) - bobAccount := absentee.From(bobMailto) - - agentA := testutil.Alice - agentB := testutil.Bob - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - dlgA := testutil.Must(delegation.Delegate(aliceAccount, agentA.DID(), space.DID(), cmd))(t) - dlgB := testutil.Must(delegation.Delegate(bobAccount, agentB.DID(), space.DID(), cmd))(t) - - var calls []recordedCall - lister := stubAttestationLister(service, []cid.Cid{dlgA.Link(), dlgB.Link()}, &calls) - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlgA, dlgB}, service.DID()) - require.NoError(t, err) - require.Len(t, attestations, 2) - require.Len(t, calls, 2) - require.Equal(t, agentA.DID(), calls[0].aud) - require.Equal(t, agentB.DID(), calls[1].aud) - }) - - t.Run("lister error is propagated", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - wantErr := errors.New("boom") - lister := func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { - return func(yield func(ucan.Invocation, error) bool) { - yield(nil, wantErr) - } - } - - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) - require.ErrorIs(t, err, wantErr) - require.Nil(t, attestations) - }) -} diff --git a/ucan/proof_chain_test.go b/ucan/proof_chain_test.go index 999133f..895f12c 100644 --- a/ucan/proof_chain_test.go +++ b/ucan/proof_chain_test.go @@ -57,7 +57,7 @@ func assertChain(t *testing.T, proofs []ucan.Delegation, links []cid.Cid, want [ } func TestProofChain_SelfIssued(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -73,7 +73,7 @@ func TestProofChain_SelfIssued(t *testing.T) { } func TestProofChain_MultiHop(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob carol := testutil.Carol @@ -96,7 +96,7 @@ func TestProofChain_MultiHop(t *testing.T) { } func TestProofChain_NoDelegations(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -108,7 +108,7 @@ func TestProofChain_NoDelegations(t *testing.T) { } func TestProofChain_BrokenChain(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -126,7 +126,7 @@ func TestProofChain_BrokenChain(t *testing.T) { } func TestProofChain_ParentCommand(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice parent := testutil.Must(command.Parse("/test"))(t) child := testutil.Must(command.Parse("/test/do"))(t) @@ -144,7 +144,7 @@ func TestProofChain_ParentCommand(t *testing.T) { } func TestProofChain_Powerline(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -163,7 +163,7 @@ func TestProofChain_Powerline(t *testing.T) { } func TestProofChain_UnrelatedCommandIgnored(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) other := testutil.Must(command.Parse("/other/op"))(t) @@ -193,7 +193,7 @@ func TestProofChain_MissingSubject(t *testing.T) { } func TestProofChain_FinderError(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) diff --git a/ucan/proof_store.go b/ucan/proof_store.go index 50ef93a..616a01f 100644 --- a/ucan/proof_store.go +++ b/ucan/proof_store.go @@ -21,7 +21,7 @@ type ProofStore interface { // i.e. if a proof is signed with a non-standard signature this function will // fetch an attestation for it, and fail if it cannot. The authority parameter // is the DID of the service we trust to be issuing attestations. - ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) + // ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) } // ContainerProofStore is a proof store backed by an in-memory container. @@ -38,9 +38,9 @@ func (cps *ContainerProofStore) ProofChain(ctx context.Context, aud did.DID, cmd return ProofChain(ctx, cps.matchDelegations, aud, cmd, sub) } -func (cps *ContainerProofStore) ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) { - return ProofAttestations(ctx, cps.listInvocations, proofs, authority) -} +// func (cps *ContainerProofStore) ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) { +// return ProofAttestations(ctx, cps.listInvocations, proofs, authority) +// } func (ps *ContainerProofStore) listDelegations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return func(yield func(ucan.Delegation, error) bool) { @@ -61,17 +61,17 @@ func (ps *ContainerProofStore) matchDelegations(ctx context.Context, aud did.DID return NewDelegationMatcher(ps.listDelegations)(ctx, aud, cmd, sub) } -func (ps *ContainerProofStore) listInvocations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { - return func(yield func(ucan.Invocation, error) bool) { - if ps.container == nil { - return - } - for _, d := range ps.container.Invocations() { - if d.Audience() == aud && d.Command() == cmd && d.Subject() == sub { - if !yield(d, nil) { - return - } - } - } - } -} +// func (ps *ContainerProofStore) listInvocations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { +// return func(yield func(ucan.Invocation, error) bool) { +// if ps.container == nil { +// return +// } +// for _, d := range ps.container.Invocations() { +// if d.Audience() == aud && d.Command() == cmd && d.Subject() == sub { +// if !yield(d, nil) { +// return +// } +// } +// } +// } +// } diff --git a/ucan/proof_store_test.go b/ucan/proof_store_test.go index 0f43d4d..89e3609 100644 --- a/ucan/proof_store_test.go +++ b/ucan/proof_store_test.go @@ -3,23 +3,19 @@ package ucanlib_test import ( "testing" - "github.com/fil-forge/libforge/commands/ucan/attest" - "github.com/fil-forge/libforge/didmailto" "github.com/fil-forge/libforge/testutil" ucanlib "github.com/fil-forge/libforge/ucan" - "github.com/fil-forge/ucantone/principal/absentee" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/container" "github.com/fil-forge/ucantone/ucan/delegation" - "github.com/fil-forge/ucantone/ucan/invocation" "github.com/stretchr/testify/require" ) func TestContainerProofStore_ProofChain(t *testing.T) { t.Run("nil container", func(t *testing.T) { ps := ucanlib.NewContainerProofStore(nil) - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -31,7 +27,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { t.Run("empty container", func(t *testing.T) { ps := ucanlib.NewContainerProofStore(container.New()) - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -42,7 +38,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("self-issued root", func(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -57,7 +53,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("multi-hop chain", func(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob carol := testutil.Carol @@ -76,7 +72,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("parent command resolves via matcher", func(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice parent := testutil.Must(command.Parse("/test"))(t) child := testutil.Must(command.Parse("/test/do"))(t) @@ -92,7 +88,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("broken chain returns empty", func(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -110,7 +106,7 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("filters by audience", func(t *testing.T) { - space := testutil.RandomSigner(t) + space := testutil.RandomIssuer(t) alice := testutil.Alice bob := testutil.Bob cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -128,8 +124,8 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) t.Run("filters by subject", func(t *testing.T) { - spaceA := testutil.RandomSigner(t) - spaceB := testutil.RandomSigner(t) + spaceA := testutil.RandomIssuer(t) + spaceB := testutil.RandomIssuer(t) alice := testutil.Alice cmd := testutil.Must(command.Parse("/test/do"))(t) @@ -146,105 +142,105 @@ func TestContainerProofStore_ProofChain(t *testing.T) { }) } -func TestContainerProofStore_ProofAttestations(t *testing.T) { - t.Run("nil container with no proofs", func(t *testing.T) { - ps := ucanlib.NewContainerProofStore(nil) - service := testutil.WebService - - attestations, err := ps.ProofAttestations(t.Context(), nil, service.DID()) - require.NoError(t, err) - require.Empty(t, attestations) - }) - - t.Run("standard signatures need no attestations", func(t *testing.T) { - service := testutil.WebService - space := testutil.RandomSigner(t) - alice := testutil.Alice - cmd := testutil.Must(command.Parse("/test/do"))(t) - - dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) - - ps := ucanlib.NewContainerProofStore(container.New()) - - attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) - require.NoError(t, err) - require.Empty(t, attestations) - }) - - t.Run("absentee-signed proof finds attestation in container", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - // account (absentee) → agent — proof needing attestation. - dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - // Authority-issued attestation for the proof. - att := testutil.Must(attest.Proof.Invoke( - service, - service.DID(), - &attest.ProofArguments{Proof: dlg.Link()}, - invocation.WithAudience(agent.DID()), - ))(t) - - ct := container.New( - container.WithDelegations(dlg), - container.WithInvocations(att), - ) - ps := ucanlib.NewContainerProofStore(ct) - - attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) - require.NoError(t, err) - require.Len(t, attestations, 1) - require.Equal(t, att.Link(), attestations[0].Link()) - }) - - t.Run("absentee-signed proof with missing attestation errors", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - ps := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) - - attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) - require.Error(t, err) - require.Nil(t, attestations) - }) - - t.Run("attestation lookup filters by audience, command, and subject", func(t *testing.T) { - service := testutil.WebService - mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) - account := absentee.From(mailtoDID) - agent := testutil.Alice - other := testutil.Bob - space := testutil.RandomSigner(t) - cmd := testutil.Must(command.Parse("/test/do"))(t) - - dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - - // Attestation targeting a different audience — should be ignored. - wrongAud := testutil.Must(attest.Proof.Invoke( - service, - service.DID(), - &attest.ProofArguments{Proof: dlg.Link()}, - invocation.WithAudience(other.DID()), - ))(t) - - ps := ucanlib.NewContainerProofStore(container.New( - container.WithDelegations(dlg), - container.WithInvocations(wrongAud), - )) - - attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) - require.Error(t, err) - require.Nil(t, attestations) - }) -} +// func TestContainerProofStore_ProofAttestations(t *testing.T) { +// t.Run("nil container with no proofs", func(t *testing.T) { +// ps := ucanlib.NewContainerProofStore(nil) +// service := testutil.WebService + +// attestations, err := ps.ProofAttestations(t.Context(), nil, service.DID()) +// require.NoError(t, err) +// require.Empty(t, attestations) +// }) + +// t.Run("standard signatures need no attestations", func(t *testing.T) { +// service := testutil.WebService +// space := testutil.RandomIssuer(t) +// alice := testutil.Alice +// cmd := testutil.Must(command.Parse("/test/do"))(t) + +// dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) + +// ps := ucanlib.NewContainerProofStore(container.New()) + +// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) +// require.NoError(t, err) +// require.Empty(t, attestations) +// }) + +// // t.Run("absentee-signed proof finds attestation in container", func(t *testing.T) { +// // service := testutil.WebService +// // mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) +// // account := absentee.From(mailtoDID) +// // agent := testutil.Alice +// // space := testutil.RandomIssuer(t) +// // cmd := testutil.Must(command.Parse("/test/do"))(t) + +// // // account (absentee) → agent — proof needing attestation. +// // dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) + +// // // Authority-issued attestation for the proof. +// // att := testutil.Must(attest.Proof.Invoke( +// // service, +// // service.DID(), +// // &attest.ProofArguments{Proof: dlg.Link()}, +// // invocation.WithAudience(agent.DID()), +// // ))(t) + +// // ct := container.New( +// // container.WithDelegations(dlg), +// // container.WithInvocations(att), +// // ) +// // ps := ucanlib.NewContainerProofStore(ct) + +// // attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) +// // require.NoError(t, err) +// // require.Len(t, attestations, 1) +// // require.Equal(t, att.Link(), attestations[0].Link()) +// // }) + +// t.Run("absentee-signed proof with missing attestation errors", func(t *testing.T) { +// service := testutil.WebService +// mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) +// account := absentee.From(mailtoDID) +// agent := testutil.Alice +// space := testutil.RandomIssuer(t) +// cmd := testutil.Must(command.Parse("/test/do"))(t) + +// dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) + +// ps := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) + +// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) +// require.Error(t, err) +// require.Nil(t, attestations) +// }) + +// t.Run("attestation lookup filters by audience, command, and subject", func(t *testing.T) { +// service := testutil.WebService +// mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) +// account := absentee.From(mailtoDID) +// agent := testutil.Alice +// other := testutil.Bob +// space := testutil.RandomIssuer(t) +// cmd := testutil.Must(command.Parse("/test/do"))(t) + +// dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) + +// // Attestation targeting a different audience — should be ignored. +// wrongAud := testutil.Must(attest.Proof.Invoke( +// service, +// service.DID(), +// &attest.ProofArguments{Proof: dlg.Link()}, +// invocation.WithAudience(other.DID()), +// ))(t) + +// ps := ucanlib.NewContainerProofStore(container.New( +// container.WithDelegations(dlg), +// container.WithInvocations(wrongAud), +// )) + +// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) +// require.Error(t, err) +// require.Nil(t, attestations) +// }) +// } diff --git a/ucan/retrieval/client_test.go b/ucan/retrieval/client_test.go index beb73e2..fac5922 100644 --- a/ucan/retrieval/client_test.go +++ b/ucan/retrieval/client_test.go @@ -11,7 +11,6 @@ import ( "github.com/fil-forge/ucantone/execution" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal" "github.com/fil-forge/ucantone/testutil" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/container" @@ -23,10 +22,10 @@ import ( // startTestServer spins up a retrieval server that registers the given // handler for `/content/retrieve` and returns its base URL plus the service -// signer. -func startTestServer(t *testing.T, handler execution.HandlerFunc) (*url.URL, principal.Signer) { +// issuer. +func startTestServer(t *testing.T, handler execution.HandlerFunc) (*url.URL, ucan.Issuer) { t.Helper() - service := testutil.RandomSigner(t) + service := testutil.RandomIssuer(t) s := retrieval.NewServer(service) s.Handle(contentRetrieve.Command, handler) httpServer := httptest.NewServer(s) @@ -38,7 +37,7 @@ func startTestServer(t *testing.T, handler execution.HandlerFunc) (*url.URL, pri func TestClient(t *testing.T) { t.Run("execute round trip", func(t *testing.T) { - alice := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) blobBytes := []byte("retrieved blob bytes") serviceURL, service := startTestServer(t, func(req execution.Request, res execution.Response) error { @@ -87,7 +86,7 @@ func TestClient(t *testing.T) { }) t.Run("with HTTP headers adds headers to every request", func(t *testing.T) { - alice := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) const headerName = "X-Test-Auth" const headerValue = "token-123" @@ -120,7 +119,7 @@ func TestClient(t *testing.T) { }) t.Run("with event listener observes request and response", func(t *testing.T) { - alice := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) serviceURL, service := startTestServer(t, func(req execution.Request, res execution.Response) error { return res.SetSuccess(datamodel.Map{}) @@ -151,7 +150,7 @@ func TestClient(t *testing.T) { }) t.Run("with HTTP client uses provided client", func(t *testing.T) { - alice := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) serviceURL, service := startTestServer(t, func(req execution.Request, res execution.Response) error { return res.SetSuccess(datamodel.Map{}) diff --git a/ucan/retrieval/server.go b/ucan/retrieval/server.go index c9765a2..cede86f 100644 --- a/ucan/retrieval/server.go +++ b/ucan/retrieval/server.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/fil-forge/ucantone/execution" - "github.com/fil-forge/ucantone/principal" "github.com/fil-forge/ucantone/server" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/container" @@ -28,12 +27,12 @@ import ( // permitted and ignored). type Server struct { *server.HTTPServer - id principal.Signer + id ucan.Issuer codec *HTTPHeaderInboundCodec } // NewServer creates a new UCAN retrieval server. -func NewServer(id principal.Signer, options ...server.HTTPOption) *Server { +func NewServer(id ucan.Issuer, options ...server.HTTPOption) *Server { codec := DefaultHTTPHeaderInboundCodec options = append(options, server.WithHTTPCodec(codec)) return &Server{ diff --git a/ucan/retrieval/server_test.go b/ucan/retrieval/server_test.go index 9b4f013..d00cd83 100644 --- a/ucan/retrieval/server_test.go +++ b/ucan/retrieval/server_test.go @@ -23,8 +23,8 @@ import ( var contentRetrieve = binding.Bind[*datamodel.Map, *datamodel.Map](command.MustParse("/content/retrieve")) func TestServer(t *testing.T) { - service := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) blobBytes := []byte("retrieved blob bytes") const customHeader = "X-Test-Custom" From 1c8ae49b97625d1fc824b8f8e7a1cd7277bbba96 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 16 Jun 2026 14:36:10 -0400 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20`attestation.Code`=20=E2=86=92?= =?UTF-8?q?=20`attestation.VarsigCode`=20(clearer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- attestation/varsig.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/attestation/varsig.go b/attestation/varsig.go index 4201db2..7163046 100644 --- a/attestation/varsig.go +++ b/attestation/varsig.go @@ -11,25 +11,25 @@ import ( func init() { // Register spec-defined signature algorithms. varsig.RegisterAlgorithmScheme(algorithm.AlgorithmDef{ - Code: Code, + Code: VarsigCode, Name: "Attested Authority", Decoder: DecodeAlgoithm, }) } -// Code is the Varsig signature algorithm code for attested signatures, under +// VarsigCode is the Varsig signature algorithm code for attested signatures, under // fil-one RFC 7. Note that the Varsig signature algorithm codes are *not* // Multicodec codes! Officially, the Varsig code table makes no provision for // extension, but we've selected a code in *Multicodec's* "private use" range, // on the theory that it should be safe. -const Code uint64 = 0x300001 +const VarsigCode uint64 = 0x300001 type Algorithm struct{} var algorithmInstance algorithm.Algorithm = Algorithm{} func (alg Algorithm) Encode() ([]byte, error) { - return varint.ToUvarint(Code), nil + return varint.ToUvarint(VarsigCode), nil } func DecodeAlgoithm(input []byte) (algorithm.Algorithm, int, error) { @@ -37,8 +37,8 @@ func DecodeAlgoithm(input []byte) (algorithm.Algorithm, int, error) { if err != nil { return nil, 0, err } - if code != Code { - return nil, n, fmt.Errorf("signature code is not attested-authority: 0x%02x, expected: 0x%02x", code, Code) + if code != VarsigCode { + return nil, n, fmt.Errorf("signature code is not attested-authority: 0x%02x, expected: 0x%02x", code, VarsigCode) } return algorithmInstance, n, nil } From e10c6a20c73a416d146ff93ec6f0ab64ba6f00a8 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Tue, 16 Jun 2026 16:32:42 -0400 Subject: [PATCH 4/7] refactor: Use `did.ValidateMethod()` --- attestation/didmailto/resolver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attestation/didmailto/resolver.go b/attestation/didmailto/resolver.go index cd30b12..6edbc7d 100644 --- a/attestation/didmailto/resolver.go +++ b/attestation/didmailto/resolver.go @@ -9,8 +9,8 @@ import ( func NewResolver(authority did.DID) did.ResolverFunc { return func(_ context.Context, d did.DID) (did.Document, error) { - if d.Method() != Method { - return did.Document{}, did.MethodNotSupportedError{Method: d.Method()} + if err := did.ValidateMethod(d, Method); err != nil { + return did.Document{}, err } doc := did.NewDocument(d) From 96290dbcd977394acb964ebc091c19e9bc505a78 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Wed, 17 Jun 2026 18:03:53 -0400 Subject: [PATCH 5/7] refactor: Break apart `ucantone/verification` * `multikey` and `absentee` go up a level to the root. * `verification.NewIssuer()` is gone; we can use `multikey.NewIssuer()` in every case we have. * The verifier factory types move `validator`. They're not inherent to *validation*, they're just used to configure the validator. --- attestation/verificationmethod.go | 12 ++++++------ identity/identity.go | 4 ++-- identity/pem.go | 4 ++-- identity/pem_test.go | 2 +- testutil/fixtures.go | 4 ++-- testutil/gen.go | 3 ++- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/attestation/verificationmethod.go b/attestation/verificationmethod.go index 6974214..0d55eec 100644 --- a/attestation/verificationmethod.go +++ b/attestation/verificationmethod.go @@ -7,7 +7,7 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ucan" - "github.com/fil-forge/ucantone/verification" + "github.com/fil-forge/ucantone/validator" ) var ( @@ -15,12 +15,12 @@ var ( AuthorityProp = "authority" ) -// NewVerifierFactory returns a [verification.Factory] for AuthorityAttestation -// verification methods. Pass it to the validator via +// NewVerifierFactory returns a [validator.VerifierFactory] for +// AuthorityAttestation verification methods. Pass it to the validator via // [validator.WithVerifierFactories]. The provided DID resolver and // verifierFactories are used to derive verifiers for the authority's own // verification methods. -func NewVerifierFactory(resolver did.Resolver, verifierFactories map[string]verification.Factory) verification.Factory { +func NewVerifierFactory(resolver did.Resolver, verifierFactories map[string]validator.VerifierFactory) validator.VerifierFactory { return func(ctx context.Context, mat did.VerificationMaterial) (ucan.Verifier, error) { authorityDidStr, ok := mat[AuthorityProp].(string) if !ok { @@ -66,12 +66,12 @@ func (m multiVerifier) String() string { return fmt.Sprintf("multiVerifier{%d verifiers: %s}", len(m), str.String()) } -func newMultiVerifier(ctx context.Context, registry map[string]verification.Factory, vms []did.VerificationMethod) (ucan.Verifier, error) { +func newMultiVerifier(ctx context.Context, registry map[string]validator.VerifierFactory, vms []did.VerificationMethod) (ucan.Verifier, error) { verifiers := make([]ucan.Verifier, 0, len(vms)) for _, vm := range vms { f, ok := registry[vm.Type] if !ok { - return nil, fmt.Errorf("%w for VM type %q", verification.ErrNoVerifierFactory, vm.Type) + return nil, fmt.Errorf("%w for VM type %q", validator.ErrNoVerifierFactory, vm.Type) } v, err := f(ctx, vm.Material) if err != nil { diff --git a/identity/identity.go b/identity/identity.go index d326f2d..717dbe4 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -5,8 +5,8 @@ import ( "os" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/verification/multikey" - "github.com/fil-forge/ucantone/verification/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" ) // Identity holds a service's cryptographic identity. It's intended to be held diff --git a/identity/pem.go b/identity/pem.go index 57f2325..bee6bee 100644 --- a/identity/pem.go +++ b/identity/pem.go @@ -7,8 +7,8 @@ import ( "encoding/pem" "fmt" - "github.com/fil-forge/ucantone/verification/multikey" - "github.com/fil-forge/ucantone/verification/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" ) // EncodeSignerToPEM encodes a signer to a PKCS#8 PEM format. The signer's key diff --git a/identity/pem_test.go b/identity/pem_test.go index b824fd5..fe21bbc 100644 --- a/identity/pem_test.go +++ b/identity/pem_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/fil-forge/libforge/identity" - "github.com/fil-forge/ucantone/verification/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/stretchr/testify/require" ) diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 4d7a47d..e0427c9 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -4,8 +4,8 @@ import ( "net/url" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/verification/multikey" - "github.com/fil-forge/ucantone/verification/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" ) var ( diff --git a/testutil/gen.go b/testutil/gen.go index 21ca60e..ed0b455 100644 --- a/testutil/gen.go +++ b/testutil/gen.go @@ -10,8 +10,9 @@ var ( RandomDigest = testutil.RandomDigest RandomDID = testutil.RandomDID RandomSigner = testutil.RandomSigner - RandomMultikeyIssuer = testutil.RandomMultikeyIssuer RandomIssuer = testutil.RandomIssuer + RandomMultikeySigner = testutil.RandomMultikeySigner + RandomMultikeyIssuer = testutil.RandomMultikeyIssuer RandomPrincipal = testutil.RandomPrincipal // Deprecated alias for RandomDigest, which is a more accurate name. From 7342a036d88a0be402e41a2dd2e45839353e01b4 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 18 Jun 2026 21:50:32 -0400 Subject: [PATCH 6/7] refactor: Remove commented code --- ucan/proof_store.go | 24 --------- ucan/proof_store_test.go | 103 --------------------------------------- 2 files changed, 127 deletions(-) diff --git a/ucan/proof_store.go b/ucan/proof_store.go index 616a01f..1ececb4 100644 --- a/ucan/proof_store.go +++ b/ucan/proof_store.go @@ -17,11 +17,6 @@ type ProofStore interface { // in strict sequence where the aud of the previous Delegation matches the iss // of the next Delegation. ProofChain(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) ([]ucan.Delegation, []cid.Cid, error) - // ProofAttestations returns a list of attestations for proofs that need them. - // i.e. if a proof is signed with a non-standard signature this function will - // fetch an attestation for it, and fail if it cannot. The authority parameter - // is the DID of the service we trust to be issuing attestations. - // ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) } // ContainerProofStore is a proof store backed by an in-memory container. @@ -38,10 +33,6 @@ func (cps *ContainerProofStore) ProofChain(ctx context.Context, aud did.DID, cmd return ProofChain(ctx, cps.matchDelegations, aud, cmd, sub) } -// func (cps *ContainerProofStore) ProofAttestations(ctx context.Context, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) { -// return ProofAttestations(ctx, cps.listInvocations, proofs, authority) -// } - func (ps *ContainerProofStore) listDelegations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return func(yield func(ucan.Delegation, error) bool) { if ps.container == nil { @@ -60,18 +51,3 @@ func (ps *ContainerProofStore) listDelegations(ctx context.Context, aud did.DID, func (ps *ContainerProofStore) matchDelegations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return NewDelegationMatcher(ps.listDelegations)(ctx, aud, cmd, sub) } - -// func (ps *ContainerProofStore) listInvocations(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { -// return func(yield func(ucan.Invocation, error) bool) { -// if ps.container == nil { -// return -// } -// for _, d := range ps.container.Invocations() { -// if d.Audience() == aud && d.Command() == cmd && d.Subject() == sub { -// if !yield(d, nil) { -// return -// } -// } -// } -// } -// } diff --git a/ucan/proof_store_test.go b/ucan/proof_store_test.go index 89e3609..beee59d 100644 --- a/ucan/proof_store_test.go +++ b/ucan/proof_store_test.go @@ -141,106 +141,3 @@ func TestContainerProofStore_ProofChain(t *testing.T) { require.Empty(t, links) }) } - -// func TestContainerProofStore_ProofAttestations(t *testing.T) { -// t.Run("nil container with no proofs", func(t *testing.T) { -// ps := ucanlib.NewContainerProofStore(nil) -// service := testutil.WebService - -// attestations, err := ps.ProofAttestations(t.Context(), nil, service.DID()) -// require.NoError(t, err) -// require.Empty(t, attestations) -// }) - -// t.Run("standard signatures need no attestations", func(t *testing.T) { -// service := testutil.WebService -// space := testutil.RandomIssuer(t) -// alice := testutil.Alice -// cmd := testutil.Must(command.Parse("/test/do"))(t) - -// dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) - -// ps := ucanlib.NewContainerProofStore(container.New()) - -// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) -// require.NoError(t, err) -// require.Empty(t, attestations) -// }) - -// // t.Run("absentee-signed proof finds attestation in container", func(t *testing.T) { -// // service := testutil.WebService -// // mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) -// // account := absentee.From(mailtoDID) -// // agent := testutil.Alice -// // space := testutil.RandomIssuer(t) -// // cmd := testutil.Must(command.Parse("/test/do"))(t) - -// // // account (absentee) → agent — proof needing attestation. -// // dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - -// // // Authority-issued attestation for the proof. -// // att := testutil.Must(attest.Proof.Invoke( -// // service, -// // service.DID(), -// // &attest.ProofArguments{Proof: dlg.Link()}, -// // invocation.WithAudience(agent.DID()), -// // ))(t) - -// // ct := container.New( -// // container.WithDelegations(dlg), -// // container.WithInvocations(att), -// // ) -// // ps := ucanlib.NewContainerProofStore(ct) - -// // attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) -// // require.NoError(t, err) -// // require.Len(t, attestations, 1) -// // require.Equal(t, att.Link(), attestations[0].Link()) -// // }) - -// t.Run("absentee-signed proof with missing attestation errors", func(t *testing.T) { -// service := testutil.WebService -// mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) -// account := absentee.From(mailtoDID) -// agent := testutil.Alice -// space := testutil.RandomIssuer(t) -// cmd := testutil.Must(command.Parse("/test/do"))(t) - -// dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - -// ps := ucanlib.NewContainerProofStore(container.New(container.WithDelegations(dlg))) - -// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) -// require.Error(t, err) -// require.Nil(t, attestations) -// }) - -// t.Run("attestation lookup filters by audience, command, and subject", func(t *testing.T) { -// service := testutil.WebService -// mailtoDID := testutil.Must(didmailto.New("alice@example.com"))(t) -// account := absentee.From(mailtoDID) -// agent := testutil.Alice -// other := testutil.Bob -// space := testutil.RandomIssuer(t) -// cmd := testutil.Must(command.Parse("/test/do"))(t) - -// dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) - -// // Attestation targeting a different audience — should be ignored. -// wrongAud := testutil.Must(attest.Proof.Invoke( -// service, -// service.DID(), -// &attest.ProofArguments{Proof: dlg.Link()}, -// invocation.WithAudience(other.DID()), -// ))(t) - -// ps := ucanlib.NewContainerProofStore(container.New( -// container.WithDelegations(dlg), -// container.WithInvocations(wrongAud), -// )) - -// attestations, err := ps.ProofAttestations(t.Context(), []ucan.Delegation{dlg}, service.DID()) -// require.Error(t, err) -// require.Nil(t, attestations) -// }) -// } From ef69fa2d154c95e2a1a47cfb413cd919dd407a99 Mon Sep 17 00:00:00 2001 From: Petra Jaros Date: Thu, 18 Jun 2026 21:52:16 -0400 Subject: [PATCH 7/7] refactor: Upgrade ucantone --- go.mod | 5 +---- go.sum | 12 ++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 221c3cd..0ec9314 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/alanshaw/dag-json-gen v0.0.6 github.com/fil-forge/automobile v0.0.1 - github.com/fil-forge/ucantone v0.0.0-20260521210642-84d8c533075b + github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88 github.com/filecoin-project/go-data-segment v0.0.1 github.com/filecoin-project/go-fil-commcid v0.3.1 github.com/filecoin-project/go-fil-commp-hashhash v0.2.0 @@ -22,7 +22,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/filecoin-project/go-state-types v0.18.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -32,8 +31,6 @@ require ( github.com/multiformats/go-base36 v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect - gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect golang.org/x/crypto v0.50.0 // indirect diff --git a/go.sum b/go.sum index 93488a1..112629d 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ github.com/alanshaw/dag-json-gen v0.0.6 h1:MiscvWVOhs6/ux7OUdPz2nDRA7GwklZyaAy4XWexpr0= +github.com/alanshaw/dag-json-gen v0.0.6/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7GfobLhA= github.com/fil-forge/automobile v0.0.1/go.mod h1:TsO7jlO8ykJZY5tF8j4GsUcu3F02lEzxO7ULoB61hRA= -github.com/fil-forge/ucantone v0.0.0-20260521210642-84d8c533075b h1:ILG7dtSWiOO/fYiesqYj8Esm1qeIxFy7qkZoFdnjPpU= -github.com/fil-forge/ucantone v0.0.0-20260521210642-84d8c533075b/go.mod h1:XAVqsZwYoZ9vncjZoRUAJ+mL/ApLMFn9HHX7ipohVdY= +github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88 h1:N0gbL3Ik+XBYk4y/5BxTVymwbRGlxRXwC5eNWzi1bGI= +github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88/go.mod h1:rTIRXz4xErI4U+YlBU9ZvhlTbr4Hs5tJhVMwereVkSg= github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs= github.com/filecoin-project/go-data-segment v0.0.1/go.mod h1:H0/NKbsRxmRFBcLibmABv+yFNHdmtl5AyplYLnb0Zv4= github.com/filecoin-project/go-fil-commcid v0.3.1 h1:4EfxpHSlvtkOqa9weG2Yt5kxFmPib2xU7Uc9Lbqk7fs= github.com/filecoin-project/go-fil-commcid v0.3.1/go.mod h1:z7Ssf8d7kspF9QRAVHDbZ+43JK4mkhbGH5lyph1TnKY= github.com/filecoin-project/go-fil-commp-hashhash v0.2.0 h1:HYIUugzjq78YvV3vC6rL95+SfC/aSTVSnZSZiDV5pCk= github.com/filecoin-project/go-fil-commp-hashhash v0.2.0/go.mod h1:VH3fAFOru4yyWar4626IoS5+VGE8SfZiBODJLUigEo4= -github.com/filecoin-project/go-state-types v0.18.0 h1:oDcjihXRlf2cM176atZzllp79Zc+kcbiuQM9DPL/1a4= -github.com/filecoin-project/go-state-types v0.18.0/go.mod h1:CcyG4ZQRDWW+QUY2WDf1KtVDRN7W4twjsfgnGbQfJVI= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= @@ -45,6 +45,7 @@ github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsC github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -62,10 +63,13 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=