From 47d18c3d064f562b416031add7ad48eb2b300319 Mon Sep 17 00:00:00 2001 From: frrist Date: Fri, 26 Jun 2026 16:27:28 -0700 Subject: [PATCH] fix(attestation): verify attested signatures from did:web authorities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attestation Verifier re-validated the attestation invocation with validator.ValidateInvocation(v.ctx, inv) and no options. ValidateInvocation reads its DID resolver from options, defaulting to key.Resolver (did:key only) — so it could not resolve a did:web authority (e.g. did:web:upload), and attested-signature verification failed with "signature mismatch". did:key authorities (and the existing TestSigner) resolved fine, which is why this slipped through. Verify the attestation invocation's signature with the authority verifier the Verifier already holds (v.authorityVerifier) instead of re-resolving the authority. Add TestSigner_WebAuthority covering a did:web authority resolved via its DID document; without this change it fails with the same "signature mismatch" seen in the wild (e.g. `guppy login`). Co-Authored-By: Claude Opus 4.8 (1M context) --- attestation/verifier.go | 10 ++++- attestation/verifier_web_test.go | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 attestation/verifier_web_test.go diff --git a/attestation/verifier.go b/attestation/verifier.go index e2e0fee..2b45461 100644 --- a/attestation/verifier.go +++ b/attestation/verifier.go @@ -12,7 +12,7 @@ import ( "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" + "github.com/fil-forge/ucantone/ucan/token" ) // Verifier is a ucan.Verifier for a DID whose signing is attested by an @@ -54,7 +54,13 @@ func (v Verifier) Verify(msg []byte, sig []byte) bool { return false } - if validator.ValidateInvocation(v.ctx, inv) != nil { + // Verify the attestation invocation was actually signed by the authority, + // using the already-resolved authority verifier directly. Previously this + // re-validated via validator.ValidateInvocation with no options, which + // defaults to a did:key-only resolver and therefore fails to resolve a + // did:web authority — the cause of "signature mismatch" for did:web services + // (e.g. did:web:upload) while did:key authorities (and unit tests) passed. + if !token.VerifySignature(inv, v.authorityVerifier) { return false } diff --git a/attestation/verifier_web_test.go b/attestation/verifier_web_test.go new file mode 100644 index 0000000..31cbb4c --- /dev/null +++ b/attestation/verifier_web_test.go @@ -0,0 +1,74 @@ +package attestation_test + +import ( + "context" + "fmt" + "testing" + + "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/validator" + + "github.com/fil-forge/libforge/attestation" + "github.com/fil-forge/libforge/attestation/didmailto" + "github.com/fil-forge/libforge/identity" + "github.com/fil-forge/libforge/testutil" +) + +// TestSigner_WebAuthority exercises an attestation whose authority is a did:web +// service (e.g. did:web:upload), resolved via its DID document — the real +// service flow. The existing TestSigner uses a did:key authority, which the +// default key.Resolver handles, so it never caught that the attestation verifier +// re-resolved the authority with the did:key-only default resolver. With a +// did:web authority that path fails ("signature mismatch"); this test guards the +// fix that verifies with the already-resolved authority verifier. +func TestSigner_WebAuthority(t *testing.T) { + authority, err := identity.New("", "did:web:example.com") + require.NoError(t, err) + + doc, err := authority.DIDDocument() + require.NoError(t, err) + + 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) + + encoded, err := delegation.Encode(del) + require.NoError(t, err) + decoded, err := delegation.Decode(encoded) + require.NoError(t, err) + + // Serve the authority's generated did:web document. + webResolver := did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + if d == authority.DID() { + return doc, nil + } + return did.Document{}, fmt.Errorf("unexpected did %s", d) + }) + resolver := did.ResolverMap{ + "key": key.Resolver, + "web": webResolver, + "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) +}