Skip to content

feat: Attested Signatures + UCAN Principal Clarification#30

Merged
Peeja merged 29 commits into
mainfrom
petra/feat/attested-signatures
Jun 19, 2026
Merged

feat: Attested Signatures + UCAN Principal Clarification#30
Peeja merged 29 commits into
mainfrom
petra/feat/attested-signatures

Conversation

@Peeja

@Peeja Peeja commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

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. Figured it out. I put the solution in its own set of PRs, but I've rebased these on top of them anyhow.

Attested Signatures + UCAN Principal Clarification

This PR (along with coordinated changes in sibling repos) reimplements attested signatures. This was written less thoroughly in 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 verificationMethods, 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 Principals. 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 Signers and Verifiers 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?

@alanshaw alanshaw left a comment

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.

Not finished review - just submitting what I have :)

Comment thread binding/example_test.go
Comment thread did/key/key.go
Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()},
}

if err := doc.VerificationMethods.Add(vm); err != 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.

nit: we're Adding here to a pluralised VerificationMethods but the others below are singular.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well, not exactly. The others aren't nouns, they're verbs. "CapabilityDelegation" doesn't contain capability delegations, it contains verification methods which can be used for the delegation of capabilities. I have pluralized VerificationMethods where the actual JSON key is verificationMethod, because…it just felt really weird not to. So we're already messing with the names a bit here, and we could mess with them more, but pluralizing the others wouldn't make sense. WDYT? What would read best to you?

Comment thread did/resolver/chain.go Outdated
// error if it cannot resolve the DID so the next tier can be tried. If all
// tiers fail, the error from each tier is aggregated and returned in a single
// error.
type Chain []did.Resolver

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.

IMO "tiered" is a better description of what this is...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tier sounded like it carried a lot of semantic baggage to me; I was going for the Chain-of-responsibility pattern here. But I'm not against changing it back. Do you have other examples of "tier" being used this way? It seemed novel to me, but that might just be my own experience.

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 at least remove "tier" from the description above?

Comment thread did/resolver/wellknown.go
)

// WellKnown is a simple resolver that looks up DIDs in a local mapping.
type WellKnown map[did.DID]did.Document

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.

👏 but this is a better name than "map" resolver

Comment thread did/web/options.go Outdated
}
}

func WithInsecure() 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.

FWIW, I find it easier to pass a bool here than have to conditionally add the option.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ooh, yes, that's much better.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, I think I must have done that because I remembered it from the otel code. That has a WithInsecure() option. But I agree, passing the explicit boolean (and being able to override it back with another option) is better.

Comment thread did/did.go Outdated

// Method returns the DID method name (e.g. "key", "web") parsed from the
// scheme. Returns "" for an undefined DID.
// scheme. Returns "" for an undefined

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.

? does not make sense

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

…I have no explanation. 😅 revert

Comment thread did/resolver/bymethod.go
Comment thread did/resolver.go
return f(ctx, d)
}

type ResolverMap map[string]Resolver

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.

Is this a duplicate of the ByMethod resolver?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh, dang, it is. That was supposed to go away. It's only used in tests. Lemme get that…

Comment thread did/resolver.go Outdated
return resolver.Resolve(ctx, d)
}

type MethodNotSupportedError struct {

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 is repeated in errors.go

Comment thread did/url.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.

Do you think we should put the code related to DID documents in a document package?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I had one for a bit, but it was clunky. There were some dependency issues, and a document was a document.Document instead of a did.Document. Fundamentally, I realized that DID documents are pretty core to what DIDs are, so it made sense to put them in the same package. We could split them out if it's just too crowded and alias things for the outside world to use from the main did package, but I don't think it actually makes it any easier to work with.

Call libforge's reusable go-workspace-test workflow so PRs that share a branch
name with sibling repos are tested against those siblings' branches. When no
sibling has a matching branch, the job is skipped and the normal go-test.yml
remains the signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@alanshaw alanshaw left a comment

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 don't have any major issues here - it'll be great to get this in. I would like to rename the import for multikeys - it's often used for generating keypairs and I think that it just very weird to be importing from a verification package to do that. Also if you ever need to import from the ed25519 verifier package you end up with verification AND verifier in the import path e.g. github.com/fil-forge/ucantone/verification/multikey/ed25519/verifier

Comment thread did/resolver/chain.go Outdated
// error if it cannot resolve the DID so the next tier can be tried. If all
// tiers fail, the error from each tier is aggregated and returned in a single
// error.
type Chain []did.Resolver

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 at least remove "tier" from the description above?

Comment thread execution/response.go Outdated
type ResponseOption func(r *ExecResponse) error

func WithSigner(signer ucan.Signer) ResponseOption {
func WithSigner(issuer ucan.Issuer) ResponseOption {

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.

Should be renamed WithIssuer?

Comment thread examples/binding_test.go Outdated
"github.com/fil-forge/ucantone/server"
"github.com/fil-forge/ucantone/ucan/command"
"github.com/fil-forge/ucantone/ucan/invocation"
"github.com/fil-forge/ucantone/verification/multikey/ed25519"

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 change from principal/ed25519 to verification/multikey/ed25519 is particularly jarring. I think it's unexpected to have to import from a verification package to generate a crypto key pair and use it to issue UCANs.

Suggestions:

  1. Remove "multikey" and rename "verification" to "crypto" e.g. github.com/fil-forge/ucantone/crypto/ed25519. We only use multikey keys here - do we need it in the import path? Using crypto echos Golang crypto from standard lib.
  2. Move multikey to top level e.g. github.com/fil-forge/ucantone/multikey/ed25519. Double down on it being "multikey". The verification package can stay, although perhaps it belongs in existing did package?
  3. Keep "principal", importing ed25519 provides a Generate function that returns an ucan.Issuer e.g. github.com/fil-forge/ucantone/principal/ed25519. The new verification/multikey/ed25519 stays as is, except without GenerateIssuer function. Most backwards compatible, least churn.

I think I'm leaning towards 2 the most...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree, I like 2 also. 👍🏻

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The verification package itself dissolved nicely: NewIssuer() was redundant with multikey.NewIssuer(), and the verifier factory types really belonged in validator all along.

Peeja added 19 commits June 17, 2026 17:31
This makes it practical to use on its own, outside of
`ValidateInvocation` (for validating a delegation, for instance).
No reason to have a registry to parse different verification method
types when they're just bags of fields. It's the verifier that uses it
that's interesting.
They weren't really being used outside of one pair of tests.
No need to filter them up front, just try them all. It's safe, and
there's rarely more than one anyway.
- did/web: new did:web resolver
- did/utilresolvers: ByMethod, Chain, Cached, WellKnown resolver
  combinators
- verification: replace global init()-based registry with explicit
  Factory/Registry types; validator now takes verifierFactories via
  DefaultFactories() + WithVerifierFactory()
- multikey: add NewIssuer, richer Signer/Verifier interfaces (Code,
  PublicKey, Raw, PrivateKey)
- did: add New(), MustParse(), ValidateMethod(), UnsupportedMethodError
@Peeja Peeja force-pushed the petra/feat/attested-signatures branch from a8b9ef2 to 0fc2ef3 Compare June 17, 2026 21:38
Peeja added 2 commits June 17, 2026 17:39
* `multikey` and `absentee` go up a level to the root.
* `verification.NewIssuer()` is gone; we can use `multikey.NewIssuer()`
  in every case we have.
* The verifier factory types move `validator`. They're not inherent to
  *validation*, they're just used to configure the validator.
@Peeja Peeja merged commit 7985ec0 into main Jun 19, 2026
1 check passed
Peeja added a commit to fil-forge/libforge that referenced this pull request Jun 19, 2026
Peeja added a commit to fil-forge/piri that referenced this pull request Jun 19, 2026
Peeja added a commit to fil-forge/sprue that referenced this pull request Jun 19, 2026
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.

2 participants