Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ucan/proof_chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,21 @@ func proofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud
if err != nil {
return nil, nil, err
}
if d.Subject() == d.Issuer() {
// A delegation is a valid root when its issuer is the invocation
// subject we're chaining toward. This covers two cases:
// 1. Self-issued: d.Issuer() == d.Subject() == sub (e.g. space
// delegating power over itself to an agent).
// 2. Powerline: d.Issuer() == sub && d.Subject() == did.Undef
// (e.g. an account delegating to an agent without specifying
// a subject — common for did:mailto accounts after login).
// The matcher only returns delegations where d.Subject() matches
// sub or is did.Undef, so we only need to check the issuer here.
if d.Issuer() == sub {

@alanshaw alanshaw May 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sub should NOT be null here - it is checked by the caller (ProofChain). The matchDelegations function should only be returning delegations with subject == sub or subject == null.

If the subject is the issuer then we are at the root, otherwise we need to keep going.

The change here allows a powerline to be the root delegation, since the delegation returned here MAY have a null subject. This is explicitly not allowed.

Powerline delegations MUST NOT be used as the root delegation to a resource. A priori there is no such thing as a null subject.
https://github.com/ucan-wg/delegation#powerline

proofs = append(proofs, d)
links = append(links, d.Link())
break
}
// if subject is nil, or subject != issuer, we need more proof
// otherwise the chain needs another hop back toward sub
ps, ls, err := proofChain(ctx, matchDelegations, d.Issuer(), d.Command(), sub)
if err != nil {
return nil, nil, err
Expand Down
25 changes: 25 additions & 0 deletions ucan/proof_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,31 @@ func TestProofChain_Powerline(t *testing.T) {
assertChain(t, proofs, links, []ucan.Delegation{root, powerline})
}

// TestProofChain_PowerlineRoot covers the case where the only delegation in
// the chain is a powerline issued by the invocation subject itself (the
// resource owner). This is the shape produced by sprue's access/confirm
// flow for a did:mailto account: account → agent with did.Undef subject.
// Without explicit handling, the recursion looks for a parent of the
// account-issued delegation, finds none, and returns an empty chain —
// causing the server to reject the invocation as "issued by non-subject
// with no proofs."
func TestProofChain_PowerlineRoot(t *testing.T) {
account := testutil.RandomSigner(t)
agent := testutil.Alice
cmd := testutil.Must(command.Parse("/test/do"))(t)

// account → agent powerline (no subject). The account is the resource
// owner; this single delegation IS the proof chain.
dlg := testutil.Must(delegation.Delegate(account, agent.DID(), did.Undef, cmd))(t)

finder := &memLister{delegations: []ucan.Delegation{dlg}}
matcher := ucanlib.NewDelegationMatcher(finder.List)

proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, agent.DID(), cmd, account.DID())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't return anything - we don't have a valid proof chain. Powerline cannot be the root delegation.

require.NoError(t, err)
assertChain(t, proofs, links, []ucan.Delegation{dlg})
}

func TestProofChain_UnrelatedCommandIgnored(t *testing.T) {
space := testutil.RandomSigner(t)
alice := testutil.Alice
Expand Down
Loading