diff --git a/didmailto/did.go b/didmailto/did.go index cbf3a20..35feeb7 100644 --- a/didmailto/did.go +++ b/didmailto/did.go @@ -47,6 +47,8 @@ func Email(d did.DID) (string, error) { return fmt.Sprintf("%s@%s", emailLocal, domain), nil } +// Parse parses a mailto DID from a string. This is the same as [did.Parse], +// with validation that the DID is a mailto DID. func Parse(str string) (did.DID, error) { d, err := did.Parse(str) if err != nil { 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_store.go b/ucan/proof_store.go index 50ef93a..b6bd666 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. @@ -29,6 +24,8 @@ type ContainerProofStore struct { container ucan.Container } +var _ ProofStore = (*ContainerProofStore)(nil) + // NewContainerProofStore creates a proof store backed by an in-memory container. func NewContainerProofStore(ct ucan.Container) *ContainerProofStore { return &ContainerProofStore{container: ct} @@ -38,10 +35,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 +53,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 0f43d4d..080b5c1 100644 --- a/ucan/proof_store_test.go +++ b/ucan/proof_store_test.go @@ -3,16 +3,12 @@ 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" ) @@ -145,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.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) - }) -}