diff --git a/ucan/capability.go b/ucan/capability.go index a07e036..ab3ca17 100644 --- a/ucan/capability.go +++ b/ucan/capability.go @@ -1,5 +1,15 @@ package ucan +import ( + "encoding/json" +) + +type jsonModel struct { + With Resource `json:"with"` + Can Ability `json:"can"` + Nb interface{} `json:"nb,omitempty"` +} + type capability[T any] struct { can Ability nb T @@ -20,6 +30,14 @@ func (c *capability[T]) With() Resource { return c.with } +func (c *capability[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(jsonModel{ + With: c.with, + Can: c.can, + Nb: c.nb, + }) +} + func NewCapability[Caveats any](can Ability, with Resource, nb Caveats) Capability[Caveats] { return &capability[Caveats]{ can: can, diff --git a/ucan/ucan.go b/ucan/ucan.go index 334eadc..9cca1d0 100644 --- a/ucan/ucan.go +++ b/ucan/ucan.go @@ -1,6 +1,8 @@ package ucan import ( + "encoding/json" + "github.com/ipld/go-ipld-prime" "github.com/web3-storage/go-ucanto/did" "github.com/web3-storage/go-ucanto/ucan/crypto" @@ -15,11 +17,17 @@ type Resource = string // It MUST have format `${string}/${string}` | "*" type Ability = string +// UnknownCapability is a capability whose Nb type is unknown +type UnknownCapability interface { + json.Marshaler + Can() Ability + With() Resource +} + // Capability represents an ability that a UCAN holder can perform with some // resource. type Capability[Caveats any] interface { - Can() Ability - With() Resource + UnknownCapability Nb() Caveats } @@ -35,7 +43,7 @@ type Link = ipld.Link // It MUST have format `${number}.${number}.${number}` type Version = string -// UTCUnixTimestamp is a timestamp in seconds since the Unix epoch. +// UTCUnixTimestamp is a timestamp in milliseconds since the Unix epoch. type UTCUnixTimestamp = uint64 // https://github.com/ucan-wg/spec/#324-nonce diff --git a/validator/datamodel/errors.go b/validator/datamodel/errors.go new file mode 100644 index 0000000..995a8ca --- /dev/null +++ b/validator/datamodel/errors.go @@ -0,0 +1,63 @@ +package datamodel + +import ( + // for go:embed + _ "embed" + "fmt" + + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/schema" +) + +//go:embed errors.ipldsch +var errorsch []byte + +var ( + errorTypeSystem *schema.TypeSystem +) + +func init() { + ts, err := ipld.LoadSchemaBytes(errorsch) + if err != nil { + panic(fmt.Errorf("failed to load IPLD schema: %s", err)) + } + errorTypeSystem = ts +} + +func InvalidAudienceType() schema.Type { + return errorTypeSystem.TypeByName("InvalidAudience") +} + +type Delegation struct { + Audience string +} + +type InvalidAudienceModel struct { + Name *string + Audience string + Delegation Delegation + Message string + Stack *string +} + +type ExpiredModel struct { + Name *string + Message string + ExpiredAt int64 + Stack *string +} + +func ExpiredType() schema.Type { + return errorTypeSystem.TypeByName("Expired") +} + +type NotValidBeforeModel struct { + Name *string + Message string + ValidAt int64 + Stack *string +} + +func NotValidBeforeType() schema.Type { + return errorTypeSystem.TypeByName("NotValidBefore") +} diff --git a/validator/datamodel/errors.ipldsch b/validator/datamodel/errors.ipldsch new file mode 100644 index 0000000..fdd20fc --- /dev/null +++ b/validator/datamodel/errors.ipldsch @@ -0,0 +1,25 @@ +type Delegation struct { + Audience String +} + +type InvalidAudience struct { + Name optional String + Audience String + Delegation Delegation + Message String + Stack optional Sstring +} + +type Expired struct { + Name optional String + Message String + ExpiredAt Integer + Stack optional String +} + +type NotValidBefore struct { + Name optional String + Message String + ValidAt Integer + Stack optional String +} diff --git a/validator/error.go b/validator/error.go new file mode 100644 index 0000000..50e8047 --- /dev/null +++ b/validator/error.go @@ -0,0 +1,391 @@ +package validator + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "time" + + "github.com/ipld/go-ipld-prime/datamodel" + "github.com/ipld/go-ipld-prime/node/bindnode" + "github.com/web3-storage/go-ucanto/core/delegation" + "github.com/web3-storage/go-ucanto/core/result" + "github.com/web3-storage/go-ucanto/did" + "github.com/web3-storage/go-ucanto/ucan" + vdm "github.com/web3-storage/go-ucanto/validator/datamodel" +) + +// go hack for union type -- unexported method cannot be implemented outside module limiting satisfying types +type DelegationSubError interface { + error + isDelegationSubError() +} + +type InvalidProofError interface { + error + isInvalidProofError() +} + +type EscalatedCapabilityError[Caveats any] struct { + result.NamedWithStackTrace + claimed ucan.Capability[Caveats] + delegated interface{} + cause error +} + +func NewEscalatedCapabilityError[Caveats any](claimed ucan.Capability[Caveats], delegated interface{}, cause error) error { + return EscalatedCapabilityError[Caveats]{result.NamedWithCurrentStackTrace("EscalatedCapability"), claimed, delegated, cause} +} + +func (ece EscalatedCapabilityError[Caveats]) Unwrap() error { + return ece.cause +} + +func (ece EscalatedCapabilityError[Caveats]) Error() string { + return fmt.Sprintf("Constraint violation: %s", ece.cause.Error()) +} + +func (ece EscalatedCapabilityError[Caveats]) isDelegationSubError() { +} + +/** + * @implements {API.DelegationError} + */ +type DelegationError struct { + result.NamedWithStackTrace + causes []DelegationSubError + context interface{} +} + +func NewDelegationError(causes []DelegationSubError, context interface{}) error { + return DelegationError{result.NamedWithCurrentStackTrace("InvalidClaim"), causes, context} +} + +func (de DelegationError) Error() string { + return fmt.Sprintf("Cannot derive %s from delegated capabilities: %s", de.context, errors.Join(de.Unwrap()...).Error()) +} + +func (de DelegationError) Unwrap() []error { + errs := make([]error, 0, len(de.causes)) + for _, cause := range de.causes { + errs = append(errs, cause) + } + return errs +} + +func (de DelegationError) isDelegationSubError() {} + +type SessionEscalationError struct { + result.NamedWithStackTrace + delegation delegation.Delegation + cause error +} + +func NewSessionEscalationError(delegation delegation.Delegation, cause error) error { + return SessionEscalationError{result.NamedWithCurrentStackTrace("SessionEscalation"), delegation, cause} +} + +func (see SessionEscalationError) Error() string { + issuer := see.delegation.Issuer().DID() + return strings.Join([]string{ + fmt.Sprintf("Delegation %s issued by %s has an invalid session", see.delegation.Link(), issuer), + li(see.cause.Error()), + }, "\n") +} + +func (see SessionEscalationError) isInvalidProofError() {} + +type InvalidSignatureError struct { + result.NamedWithStackTrace + delegation delegation.Delegation + verifier ucan.Verifier +} + +func NewInvalidSignatureError(delegation delegation.Delegation, verifier ucan.Verifier) error { + return InvalidSignatureError{result.NamedWithCurrentStackTrace("InvalidSignature"), delegation, verifier} +} + +func (ise InvalidSignatureError) Issuer() ucan.Principal { + return ise.delegation.Issuer() +} +func (ise InvalidSignatureError) Audience() ucan.Principal { + return ise.delegation.Audience() +} + +func (ise InvalidSignatureError) Error() string { + issuer := ise.Issuer().DID() + key := ise.verifier.DID() + if !strings.HasPrefix(issuer.String(), "did:key") { + return fmt.Sprintf(`Proof %s does not has a valid signature from %s`, ise.delegation.Link(), key) + } + return strings.Join([]string{ + fmt.Sprintf("Proof %s issued by %s does not has a valid signature from %s", ise.delegation.Link(), issuer, key), + " ℹ️ Probably issuer signed with a different key, which got rotated, invalidating delegations that were issued with prior keys", + }, "\n") +} + +func (ise InvalidSignatureError) isInvalidProofError() {} + +type UnavailableProofError struct { + result.NamedWithStackTrace + link ucan.Link + cause error +} + +func NewUnavailableProofError(link ucan.Link, cause error) error { + return UnavailableProofError{result.NamedWithCurrentStackTrace("UnavailableProof"), link, cause} +} + +func (upe UnavailableProofError) Unwrap() error { + return upe.cause +} + +func (upe UnavailableProofError) Error() string { + messages := []string{ + fmt.Sprintf("Linked proof '%s' is not included and could not be resolved", upe.link), + } + if upe.cause != nil { + messages = append(messages, li(fmt.Sprintf("Proof resolution failed with: %s", upe.cause.Error()))) + } + return strings.Join(messages, "\n") +} + +func (upe UnavailableProofError) isInvalidProofError() {} + +type DIDKeyResolutionError struct { + result.NamedWithStackTrace + did did.DID + cause error +} + +func NewDIDKeyResolutionError(did did.DID, cause error) error { + return DIDKeyResolutionError{result.NamedWithCurrentStackTrace("DIDKeyResolutionError"), did, cause} +} + +func (dkre DIDKeyResolutionError) Unwrap() error { + return dkre.cause +} + +func (dkre DIDKeyResolutionError) Error() string { + return fmt.Sprintf("Unable to resolve '%s' key", dkre.did) +} + +func (dkre DIDKeyResolutionError) isInvalidProofError() {} + +type PrincipalAlignmentError struct { + result.NamedWithStackTrace + audience ucan.Principal + delegation delegation.Delegation +} + +func NewPrincipalAlignmentError(audience ucan.Principal, delegation delegation.Delegation) error { + return PrincipalAlignmentError{result.NamedWithCurrentStackTrace("InvalidAudience"), audience, delegation} +} + +func (pae PrincipalAlignmentError) Error() string { + return fmt.Sprintf("Delegation audience is '%s' instead of '%s'", pae.delegation.Audience().DID(), pae.audience.DID()) +} + +func (pae PrincipalAlignmentError) ToIPLD() datamodel.Node { + name := pae.Name() + stack := pae.Stack() + invalidAudienceModel := vdm.InvalidAudienceModel{ + Name: &name, + Audience: pae.audience.DID().String(), + Delegation: vdm.Delegation{Audience: pae.delegation.Audience().DID().String()}, + Message: pae.Error(), + Stack: &stack, + } + return bindnode.Wrap(&invalidAudienceModel, vdm.InvalidAudienceType()) +} + +func (pae PrincipalAlignmentError) isInvalidProofError() {} + +type MalformedCapabilityError[Caveats any] struct { + result.NamedWithStackTrace + capability ucan.Capability[Caveats] + cause error +} + +func NewMalformedCapabilityError[Caveats any](capability ucan.Capability[Caveats], cause error) error { + return MalformedCapabilityError[Caveats]{result.NamedWithCurrentStackTrace("MalformedCapability"), capability, cause} +} + +func (mce MalformedCapabilityError[Caveats]) Error() string { + capabilityJSON, _ := json.Marshal(mce.capability) + return strings.Join([]string{ + fmt.Sprintf("Encountered malformed '%s' capability: %s", mce.capability.Can(), string(capabilityJSON)), + li(mce.cause.Error()), + }, "\n") +} + +func (mce MalformedCapabilityError[Caveats]) isDelegationSubError() {} + +type UnknownCapabilityError[Caveats any] struct { + result.NamedWithStackTrace + capability ucan.Capability[Caveats] +} + +func NewUnknownCapabilityError[Caveats any](capability ucan.Capability[Caveats]) error { + return UnknownCapabilityError[Caveats]{result.NamedWithCurrentStackTrace("UnknownCapability"), capability} +} + +func (uce UnknownCapabilityError[Caveats]) Error() string { + capabilityJSON, _ := json.Marshal(uce.capability) + return fmt.Sprintf("Encountered unknown capability: %s", string(capabilityJSON)) +} + +func (uce UnknownCapabilityError[Caveats]) isDelegationSubError() {} + +type ExpiredError struct { + result.NamedWithStackTrace + delegation delegation.Delegation +} + +func NewExpiredError(delegation delegation.Delegation) error { + return ExpiredError{result.NamedWithCurrentStackTrace("Expired"), delegation} +} + +func (ee ExpiredError) Error() string { + return fmt.Sprintf("Proof %s has expired on %s", ee.delegation.Link(), + time.UnixMilli(int64(ee.delegation.Expiration())).Format(time.RFC3339)) +} + +func (ee ExpiredError) ToIPLD() datamodel.Node { + name := ee.Name() + stack := ee.Stack() + expiredModel := vdm.ExpiredModel{ + Name: &name, + Message: ee.Error(), + ExpiredAt: int64(ee.delegation.Expiration()), + Stack: &stack, + } + return bindnode.Wrap(expiredModel, vdm.ExpiredType()) +} + +func (ee ExpiredError) isInvalidProofError() {} + +type RevokedError struct { + result.NamedWithStackTrace + delegation delegation.Delegation +} + +func NewRevokedError(delegation delegation.Delegation) error { + return RevokedError{result.NamedWithCurrentStackTrace("Revoked"), delegation} +} + +func (re RevokedError) Error() string { + return fmt.Sprintf("Proof %s has been revoked", re.delegation.Link()) +} + +func (re RevokedError) isInvalidProofError() {} + +type NotValidBeforeError struct { + result.NamedWithStackTrace + delegation delegation.Delegation +} + +func NewNotValidBeforeERror(delegation delegation.Delegation) error { + return NotValidBeforeError{result.NamedWithCurrentStackTrace("NotValidBefore"), delegation} +} + +func (nvbe NotValidBeforeError) Error() string { + return fmt.Sprintf("Proof %s is not valid before %s", nvbe.delegation.Link(), + time.UnixMilli(int64(nvbe.delegation.NotBefore())).Format(time.RFC3339)) +} + +func (nvbe NotValidBeforeError) ToIPLD() datamodel.Node { + name := nvbe.Name() + stack := nvbe.Stack() + notValidBeforeModel := vdm.NotValidBeforeModel{ + Name: &name, + Message: nvbe.Error(), + ValidAt: int64(nvbe.delegation.NotBefore()), + Stack: &stack, + } + return bindnode.Wrap(notValidBeforeModel, vdm.NotValidBeforeType()) +} + +func (nvbe NotValidBeforeError) isInvalidProofError() {} + +// TODO: this may just be the concrete type from the implementation once +// the rest of the validator is done +type InvalidClaim interface { + result.NamedWithStackTrace + error + Issuer() ucan.Principal + Delegation() delegation.Delegation +} + +type UnauthorizedError[Caveats any] struct { + result.NamedWithStackTrace + capability ucan.Capability[Caveats] + delegationErrors []DelegationError + // this is a hack... it will allow you to make an array of capabilities of different types + unknownCapabilities []ucan.UnknownCapability + invalidProofs []InvalidProofError + failedProofs []InvalidClaim +} + +func NewUnauthorizedError[Caveats any]( + capability ucan.Capability[Caveats], + delegationErrors []DelegationError, + unknownCapabilities []ucan.UnknownCapability, + invalidProofs []InvalidProofError, + failedProofs []InvalidClaim, +) error { + return UnauthorizedError[Caveats]{ + result.NamedWithCurrentStackTrace("Unauthorized"), + capability, + delegationErrors, + unknownCapabilities, + invalidProofs, + failedProofs, + } +} + +func (ue UnauthorizedError[Caveats]) Error() string { + errorStrings := make([]string, 0, len(ue.failedProofs)+len(ue.delegationErrors)+len(ue.invalidProofs)) + + for _, failedProof := range ue.failedProofs { + errorStrings = append(errorStrings, li(failedProof.Error())) + } + + for _, delegationError := range ue.delegationErrors { + errorStrings = append(errorStrings, li(delegationError.Error())) + } + + for _, invalidProof := range ue.invalidProofs { + errorStrings = append(errorStrings, li(invalidProof.Error())) + } + + unknowns := make([]string, 0, len(ue.unknownCapabilities)) + for _, unknownCapability := range ue.unknownCapabilities { + out, _ := unknownCapability.MarshalJSON() + unknowns = append(unknowns, li(string(out))) + } + + finalList := make([]string, 0, 2+int(math.Min(1, float64(len(errorStrings))))) + finalList = append(finalList, fmt.Sprintf("Claim %+v is not authorized", ue.capability)) + if len(errorStrings) > 0 { + finalList = append(finalList, errorStrings...) + } else { + finalList = append(finalList, li("No matching delegated capability found")) + } + if len(unknowns) > 0 { + finalList = append(finalList, li(fmt.Sprintf("Encountered unknown capabilities\n%s", strings.Join(unknowns, "\n")))) + } + + return strings.Join(finalList, "\n") +} + +func indent(message string) string { + indent := " " + return indent + strings.Join(strings.Split(message, "\n"), "\n$"+indent) +} + +func li(message string) string { + return indent("- " + message) +} diff --git a/validator/util.go b/validator/util.go new file mode 100644 index 0000000..f5e2969 --- /dev/null +++ b/validator/util.go @@ -0,0 +1,38 @@ +package validator + +func combine[T any](dataset [][]T) [][]T { + first, rest := dataset[0], dataset[1:] + results := make([][]T, 0, len(first)) + for _, value := range first { + results = append(results, []T{value}) + } + for _, values := range rest { + tuples := results + results = make([][]T, 0, len(tuples)) + for _, value := range values { + for _, tuple := range tuples { + newTuple := make([]T, len(tuple), len(tuple)+1) + _ = copy(newTuple, tuple) + results = append(results, append(newTuple, value)) + } + } + } + return results +} + +func intersection[T comparable](left []T, right []T) []T { + set := make([]T, 0) + hash := make(map[T]struct{}) + + for _, v := range left { + hash[v] = struct{}{} + } + + for _, v := range right { + if _, ok := hash[v]; ok { + set = append(set, v) + } + } + + return set +}