diff --git a/ucan/proof_chain.go b/ucan/proof_chain.go index e4e2de0..2e7795f 100644 --- a/ucan/proof_chain.go +++ b/ucan/proof_chain.go @@ -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 { 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 diff --git a/ucan/proof_chain_test.go b/ucan/proof_chain_test.go index 999133f..2006c0a 100644 --- a/ucan/proof_chain_test.go +++ b/ucan/proof_chain_test.go @@ -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()) + require.NoError(t, err) + assertChain(t, proofs, links, []ucan.Delegation{dlg}) +} + func TestProofChain_UnrelatedCommandIgnored(t *testing.T) { space := testutil.RandomSigner(t) alice := testutil.Alice