Skip to content

feat: AuthorityAttestation signatures#15

Open
Peeja wants to merge 7 commits into
mainfrom
petra/feat/rfc7-signatures
Open

feat: AuthorityAttestation signatures#15
Peeja wants to merge 7 commits into
mainfrom
petra/feat/rfc7-signatures

Conversation

@Peeja

@Peeja Peeja commented May 21, 2026

Copy link
Copy Markdown
Contributor

Implements and uses RFC 7 AuthorityAttestation signatures.

Depends on fil-forge/ucantone#28.

PR Dependency Tree

This tree was auto-generated by Charcoal

@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch 4 times, most recently from e57487f to 2ee6c0f Compare May 22, 2026 16:26
@Peeja Peeja changed the base branch from ash/feat/ucan1 to petra/refactor/unpack-errors-with-binding May 22, 2026 16:26
@Peeja Peeja force-pushed the petra/refactor/unpack-errors-with-binding branch from d042656 to daae365 Compare May 22, 2026 16:54
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch 2 times, most recently from 98023c8 to 96c9ae8 Compare May 22, 2026 17:39
@Peeja Peeja changed the base branch from petra/refactor/unpack-errors-with-binding to petra/chore/upgrade-ucantone May 22, 2026 17:39
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch from 96c9ae8 to 0e2bd37 Compare May 27, 2026 14:43
@Peeja Peeja force-pushed the petra/chore/upgrade-ucantone branch from 3a0823b to 1c0dd46 Compare May 27, 2026 14:47
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch from 0e2bd37 to 1466ab7 Compare May 27, 2026 14:47
@Peeja Peeja force-pushed the petra/chore/upgrade-ucantone branch from 1c0dd46 to fba76d3 Compare May 27, 2026 14:48
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch from 1466ab7 to d949177 Compare May 27, 2026 14:48
@Peeja Peeja marked this pull request as ready for review May 27, 2026 14:48
@Peeja Peeja requested review from alanshaw and frrist and removed request for alanshaw May 27, 2026 14:50
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch from d949177 to 7bec4bf Compare May 27, 2026 15:13
@Peeja Peeja changed the base branch from petra/chore/upgrade-ucantone to ash/feat/ucan1 May 27, 2026 15:13
Comment thread pkg/attested/signer.go Outdated
var _ varsig.SignatureAlgorithm = SignatureAlgorithm{}

func (SignatureAlgorithm) Code() uint64 {
return 0x300001

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.

can we make this a named constant? i.e. whats magic about this value?

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.

+1 with comment to explain multicodec "private use" value.

Comment thread pkg/attested/signer.go
Comment on lines +62 to +71
func (SignatureAlgorithm) Segments() []uint64 {
return []uint64{}
}

func (SignatureAlgorithm) Decode([]byte) (SignatureAlgorithm, int, error) {
return SignatureAlgorithm{}, 0, nil
}
func (SignatureAlgorithm) Encode() ([]byte, error) {
return []byte{}, nil
}

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.

Shouldn't these be completed?

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.

Yeah, Segments() should have at minimum the code (0x300001). Encoding/decoding should encode/decode the uvarint for the code.

Comment thread pkg/attested/signer_test.go Outdated
Comment on lines +21 to +24
alice, err := did.Parse("did:mailto:example.com:alice")
if err != nil {
t.Fatalf("failed to parse DID: %v", err)
}

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.

nit: use require

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.

Can we add a test for round tripping, e.g.

  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 := attested.NewDIDVerifierResolver(authority.Verifier())
      v, err := resolver(t.Context(), alice)
      require.NoError(t, err)

      require.True(t, v.Verify(decoded.SignedBytes(), decoded.Signature().Bytes()))
  })

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.

...which should fail right now due to missing encode/decode implementation in signature algorithm.

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.

Alternatively a varsig encode/decode round trip.

logger := zaptest.NewLogger(t)
id := newTestIdentity(t)

var validationOpts = []validator.Option{

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.

nit: slight preference for validationOpts := ...

Comment thread pkg/attested/signer.go
Comment on lines +62 to +71
func (SignatureAlgorithm) Segments() []uint64 {
return []uint64{}
}

func (SignatureAlgorithm) Decode([]byte) (SignatureAlgorithm, int, error) {
return SignatureAlgorithm{}, 0, nil
}
func (SignatureAlgorithm) Encode() ([]byte, error) {
return []byte{}, nil
}

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.

Yeah, Segments() should have at minimum the code (0x300001). Encoding/decoding should encode/decode the uvarint for the code.

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.

Can we add a test that asserts that the validator will correctly validate an invocation that uses an attested delegation as a proof?

Comment thread pkg/attested/signer.go

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.

I think this package needs to live in libforge so that the signature algorithm and resolver can be used in other services.

Comment thread pkg/attested/signer.go Outdated
var _ varsig.SignatureAlgorithm = SignatureAlgorithm{}

func (SignatureAlgorithm) Code() uint64 {
return 0x300001

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.

+1 with comment to explain multicodec "private use" value.

Base automatically changed from ash/feat/ucan1 to main May 28, 2026 21:32
@Peeja Peeja force-pushed the petra/feat/rfc7-signatures branch from 7bec4bf to e649b65 Compare June 1, 2026 17:29
Peeja added a commit to fil-forge/ucantone that referenced this pull request Jun 19, 2026
_This is part of a set of PRs across the `fil-forge` repos. They all
work together using a `go.work`. I'm not sure how to make CI do anything
useful, though._

# Attested Signatures + UCAN Principal Clarification

This PR (along with coordinated changes in sibling repos) reimplements
[attested signatures](fil-one/RFC#7). This was
written less thoroughly in
[sprue#15](fil-forge/sprue#15), but it needed to
be more central for everything in the network to use it.

Pulling on that thread unraveled a whole bunch of latent issues with the
domain model in UCAN. Nothing fundamentally wrong, just some things that
were conflated that are now teased apart to reflect some subtle but
important distinctions.

## Attested Signatures

To recap: `did:mailto:` DIDs have no native key material. Rather than
signing directly, a mailto DID delegates to a trusted **authority** (the
signing service), which issues a `/ucan/attest/proof` invocation over a
SHA2-256 hash of the message. That invocation *is* the signature. On the
verify side, the signature is decoded as an invocation, the hash is
checked against the message, and the invocation is validated against the
authority.

## DID Documents as First-Class Objects

Previously, we "resolved" DIDs directly to verifiers. Now DID documents
are a first-class concept in the `did` package, rather than being
reimplemented in multiple codebases.

The previous code assumed that there was a one-to-one mapping between
DIDs and signers/verifiers. That's a natural asssumption when most
things are `did:key:`s, where that's true. But in general, a DID
document can have multiple `verificationMethod`s, and *that's* what
actually maps to a signer/verifier pair.

Now we can resolve a DID to a document, and turn a *verification method*
within it into a verifier. We can also make a "multi-verifier" (which
succeeds if any of them succeed) out of all of the verification methods
in the document, or out of just those with a particular verification
relationship (notably `CapabilityInvocation` and
`CapabilityDelegation`).

`did.Document` is now a real typed struct with verification
relationships. Resolvers return documents; verifier derivation is a
separate step via a pluggable factory registry. This is what makes
custom verification method types (like `AuthorityAttestation`) possible.

For `did:mailto:`, `didmailto.Resolver` generates synthetic DID
documents on-the-fly. Each document contains an `AuthorityAttestation`
verification method naming the authority, and the registered factory for
that type produces an `AttestedVerifier`.

## UCAN 1.0 Terminology

The refactor also tightens up the principal/signing model:

- **`Principal`** remains "something with a DID()".
- **`Signer`** and **`Verifier`** are no longer `Principal`s. They only
deal in signatures. A verification method exists independently of a DID,
and a signer or verifier exists independently for the same reason. But
they're often tied together, so…
- **`Issuer`** is now the noun for "a `Signer` tied to a `Principal`".
In many cases, `Issuer` simply replaces the existing use of `Signer`.
Notably, there are lot of variables already named `issuer` which were
`ucan.Signer` and are now `ucan.Issuer`, which gives me some confidence
that this is correct.
- **`multikey`** is now a specific family of `Signer`s and `Verifier`s
which are based on cryptographic keys which the DID document represents
as `Multikey`-type verification methods, specifically Ed25519 and
secp256k1 currently. There's a set of enhanced `multikey.Issuer`,
`multikey.Signer`, and `multikey.Verifier` types which know about the
keys they represent.
- **`did:key:`s are not keys** and vice versa. Because of the conflation
of signers/verifiers and identities, keys were often represented as the
corresponding `did:key:`s. But these are different things: a *key* is a
method of signing and verification, while a *DID* is something that
identifies a subject. The subject of a `did:key:` is, generally
speaking, the entity which holds the private key—which is different from
being the key itself! Now that's clearer.
- Thus **we no longer "wrap" keys**. Previously, you'd accomplish a
`did:web:` signer by creating a `did:key:` signer and wrapping it with a
`did:web:` so that it reported that as its DID. Now they're separate
concerns. The equivalent of "wrapping" is `multikey.NewIssuer(did.DID,
multikey.Signer) multikey.Issuer`. The equivalent of unwrapping is
`anIssuer.PrivateKey()`—which returns an actual *key*, not a principal.
- We also have `identity.Identity`. This was promoted from `sprue` to
solve the same problem in other modules. `Identity` simply wraps an
`Issuer` to provide a `DIDDocument()` factory, which can then be used
both to serve the DID document on the web and to resolve one's own DID.
`Identity` is intended to be used for "our" identity in any given
service. The fact that it's such a simple wrapper seems like a smell to
me, but it's useful and I didn't want to mess with it too much. It might
want a little massaging in the future.

## Questions for Reviewers

- For `attested`, the verification needs a context, because it needs to
recursively validate the invocation that is the attestation signature.
But `Verify()` doesn't take a context right now, so the verifier holds a
context given at creation. That's a bit odd. Should `Verify()` change to
take a context, or should we keep doing this?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants