diff --git a/impl/cmd/main.go b/impl/cmd/main.go index 2f996430..0c6cda18 100644 --- a/impl/cmd/main.go +++ b/impl/cmd/main.go @@ -16,15 +16,6 @@ import ( var commitHash string -// main godoc -// -// @title The DID DHT Service -// @description The DID DHT Service -// @contact.name TBD -// @contact.url https://github.com/TBD54566975/did-dht-method -// @contact.email tbd-developer@squareup.com -// @license.name Apache 2.0 -// @license.url http://www.apache.org/licenses/LICENSE-2.0.html func main() { logrus.Info("Starting up...") diff --git a/impl/docs/swagger.yaml b/impl/docs/swagger.yaml index b8389710..1eec7fc6 100644 --- a/impl/docs/swagger.yaml +++ b/impl/docs/swagger.yaml @@ -1,10 +1,196 @@ definitions: + did.Document: + properties: + '@context': {} + alsoKnownAs: + type: string + assertionMethod: + items: {} + type: array + authentication: + items: {} + type: array + capabilityDelegation: + items: {} + type: array + capabilityInvocation: + items: {} + type: array + controller: + type: string + id: + description: |- + As per https://www.w3.org/TR/did-core/#did-subject intermediate representations of DID Documents do not + require an ID property. The provided test vectors demonstrate IRs. As such, the property is optional. + type: string + keyAgreement: + items: {} + type: array + service: + items: + $ref: '#/definitions/did.Service' + type: array + verificationMethod: + items: + $ref: '#/definitions/github_com_TBD54566975_ssi-sdk_did.VerificationMethod' + type: array + type: object + did.Service: + properties: + accept: + items: + type: string + type: array + id: + type: string + routingKeys: + items: + type: string + type: array + serviceEndpoint: + description: |- + A string, map, or set composed of one or more strings and/or maps + All string values must be valid URIs + type: + type: string + required: + - id + - serviceEndpoint + - type + type: object + github_com_TBD54566975_did-dht-method_internal_did.TypeIndex: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + type: integer + x-enum-varnames: + - Discoverable + - Organization + - GovernmentOrganization + - Corporation + - LocalBusiness + - SoftwarePackage + - WebApplication + - FinancialInstitution + github_com_TBD54566975_did-dht-method_pkg_service.TypeMapping: + properties: + type: + type: string + type_index: + $ref: '#/definitions/github_com_TBD54566975_did-dht-method_internal_did.TypeIndex' + required: + - type + - type_index + type: object + github_com_TBD54566975_ssi-sdk_did.VerificationMethod: + properties: + blockchainAccountId: + description: for PKH DIDs - https://github.com/w3c-ccg/did-pkh/blob/90b28ad3c18d63822a8aab3c752302aa64fc9382/did-pkh-method-draft.md + type: string + controller: + type: string + id: + type: string + publicKeyBase58: + type: string + publicKeyJwk: + allOf: + - $ref: '#/definitions/jwx.PublicKeyJWK' + description: must conform to https://datatracker.ietf.org/doc/html/rfc7517 + publicKeyMultibase: + description: https://datatracker.ietf.org/doc/html/draft-multiformats-multibase-03 + type: string + type: + type: string + required: + - controller + - id + - type + type: object + jwx.PublicKeyJWK: + properties: + alg: + type: string + crv: + type: string + e: + type: string + key_ops: + type: string + kid: + type: string + kty: + type: string + "n": + type: string + use: + type: string + x: + type: string + "y": + type: string + required: + - kty + type: object + pkg_server.GetDIDResponse: + properties: + did: + $ref: '#/definitions/did.Document' + sequence_numbers: + items: + type: integer + type: array + types: + items: + $ref: '#/definitions/github_com_TBD54566975_did-dht-method_internal_did.TypeIndex' + type: array + required: + - did + type: object + pkg_server.GetDIDsForTypeResponse: + properties: + dids: + items: + type: string + type: array + type: object pkg_server.GetHealthCheckResponse: properties: status: description: Status is always equal to `OK`. type: string type: object + pkg_server.GetTypesResponse: + properties: + types: + items: + $ref: '#/definitions/github_com_TBD54566975_did-dht-method_pkg_service.TypeMapping' + type: array + type: object + pkg_server.PublishDIDRequest: + properties: + retention_proof: + type: string + seq: + type: integer + sig: + type: string + v: + type: string + required: + - seq + - sig + - v + type: object +externalDocs: + description: OpenAPI + url: https://swagger.io/resources/open-api/ info: contact: email: tbd-developer@squareup.com @@ -15,6 +201,7 @@ info: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html title: The DID DHT Service + version: "0.1" paths: /{id}: get: @@ -83,6 +270,142 @@ paths: summary: PutRecord a Pkarr record into the DHT tags: - Pkarr + /dids/{id}: + get: + consumes: + - application/json + description: Get a DID document + parameters: + - description: ID of the record to get + in: path + name: id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_server.GetDIDResponse' + "400": + description: Invalid request + schema: + type: string + "404": + description: DID not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + "501": + description: Historical resolution not supported by this gateway + schema: + type: string + summary: Get a DID document + tags: + - DID + put: + consumes: + - application/json + description: Publish a DID document to the DHT + parameters: + - description: ID of the record to get + in: path + name: id + required: true + type: string + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/pkg_server.PublishDIDRequest' + "400": + description: Invalid request body + schema: + type: string + "401": + description: Invalid signature + schema: + type: string + "409": + description: DID already exists with a higher sequence number + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Publish a DID document + tags: + - DID + /dids/types: + get: + consumes: + - application/json + description: Get a list of supported types + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_server.GetTypesResponse' + "501": + description: Type indexing is not supported by this gateway + schema: + type: string + summary: Get a list of supported types + tags: + - DID + /dids/types/{id}: + get: + consumes: + - application/json + description: Get a list of DIDs for a given type + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_server.GetDIDsForTypeResponse' + "400": + description: Invalid request + schema: + type: string + "404": + description: Type not found + schema: + type: string + "500": + description: Internal server error + schema: + type: string + "501": + description: Type indexing is not supported by this gateway + schema: + type: string + summary: Get a list of DIDs for a given type + tags: + - DID + /difficulty: + get: + consumes: + - application/json + description: Get the current difficulty for the gateway's retention proof feature + responses: + "200": + description: OK + schema: + type: integer + "500": + description: Internal server error + schema: + type: string + "501": + description: Retention proofs are not supported by this gateway + schema: + type: string + summary: Get the current difficulty for the gateway's retention proof feature + tags: + - DID /health: get: consumes: diff --git a/impl/internal/did/did.go b/impl/internal/did/did.go index 0fc9e5cb..8dcbc4e1 100644 --- a/impl/internal/did/did.go +++ b/impl/internal/did/did.go @@ -4,6 +4,7 @@ import ( "crypto/ed25519" "encoding/base64" "fmt" + "math" "strconv" "strings" @@ -26,6 +27,7 @@ const ( DHTMethod did.Method = "dht" JSONWebKeyType cryptosuite.LDKeyType = "JsonWebKey" + Discoverable TypeIndex = 0 Organization TypeIndex = 1 GovernmentOrganization TypeIndex = 2 Corporation TypeIndex = 3 @@ -73,6 +75,24 @@ type VerificationMethod struct { Purposes []did.PublicKeyPurpose `json:"purposes"` } +func GenerateVanityDIDDHT(prefix string, opts CreateDIDDHTOpts) (ed25519.PrivateKey, *did.Document, error) { + // generate the identity key + for i := 0; i < math.MaxInt32; i++ { + pubKey, privKey, err := crypto.GenerateEd25519Key() + if err != nil { + return nil, nil, err + } + + id := GetDIDDHTIdentifier(pubKey) + + if strings.HasPrefix(id, prefix) { + doc, err := CreateDIDDHTDID(pubKey, opts) + return privKey, doc, err + } + } + return nil, nil, fmt.Errorf("failed to generate vanity did:dht identifier with prefix %s", prefix) +} + // GenerateDIDDHT generates a did:dht identifier given a set of options func GenerateDIDDHT(opts CreateDIDDHTOpts) (ed25519.PrivateKey, *did.Document, error) { // generate the identity key diff --git a/impl/internal/did/did_test.go b/impl/internal/did/did_test.go index 378a04ee..9c1ee606 100644 --- a/impl/internal/did/did_test.go +++ b/impl/internal/did/did_test.go @@ -3,6 +3,7 @@ package did import ( "crypto/ed25519" "testing" + "time" "github.com/goccy/go-json" @@ -309,3 +310,16 @@ func TestVectors(t *testing.T) { } }) } + +func TestVanityDID(t *testing.T) { + now := time.Now() + pk, doc, err := GenerateVanityDIDDHT("gabe", CreateDIDDHTOpts{}) + require.NoError(t, err) + require.NotEmpty(t, pk) + require.NotEmpty(t, doc) + + b, _ := json.Marshal(doc) + println(string(b)) + + println(time.Since(now).String()) +} diff --git a/impl/internal/util/errors.go b/impl/internal/util/errors.go new file mode 100644 index 00000000..bf06acb5 --- /dev/null +++ b/impl/internal/util/errors.go @@ -0,0 +1,19 @@ +package util + +type InvalidSignatureError struct{} + +func (e *InvalidSignatureError) Error() string { + return "invalid signature" +} + +type HigherSequenceNumberError struct{} + +func (e *HigherSequenceNumberError) Error() string { + return "DID already exists with a higher sequence number" +} + +type TypeNotFoundError struct{} + +func (e *TypeNotFoundError) Error() string { + return "type not found" +} diff --git a/impl/magefile.go b/impl/magefile.go index d60977b9..6b48e6bd 100644 --- a/impl/magefile.go +++ b/impl/magefile.go @@ -118,7 +118,7 @@ func Spec() error { return err } - return sh.Run(swagCommand, "init", "-g", "cmd/main.go", "--overridesFile", "docs/overrides.swaggo", "--pd", "--parseInternal", "-ot", "yaml") + return sh.Run(swagCommand, "init", "-g", "pkg/server/server.go", "--overridesFile", "docs/overrides.swaggo", "--pd", "--parseInternal", "-ot", "yaml") } func ColorizeTestOutput(w io.Writer) io.Writer { diff --git a/impl/pkg/server/gateway.go b/impl/pkg/server/gateway.go index abb4e431..a408ed2e 100644 --- a/impl/pkg/server/gateway.go +++ b/impl/pkg/server/gateway.go @@ -1 +1,230 @@ package server + +import ( + "net/http" + "strconv" + + "github.com/TBD54566975/ssi-sdk/did" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + didint "github.com/TBD54566975/did-dht-method/internal/did" + "github.com/TBD54566975/did-dht-method/internal/util" + "github.com/TBD54566975/did-dht-method/pkg/service" +) + +type GatewayRouter struct { + service *service.GatewayService +} + +func NewGatewayRouter(service *service.GatewayService) (*GatewayRouter, error) { + return &GatewayRouter{service: service}, nil +} + +// PublishDIDRequest represents a request to publish a DID +type PublishDIDRequest struct { + Sig string `json:"sig" validate:"required"` + Seq int64 `json:"seq" validate:"required"` + V string `json:"v" validate:"required"` + RetentionProof string `json:"retention_proof,omitempty"` +} + +func (p PublishDIDRequest) toServiceRequest(did string) service.PublishDIDRequest { + return service.PublishDIDRequest{ + DID: did, + Sig: p.Sig, + Seq: p.Seq, + V: p.V, + RetentionProof: p.RetentionProof, + } +} + +// PublishDID godoc +// +// @Summary Publish a DID document +// @Description Publish a DID document to the DHT +// @Tags DID +// @Accept json +// @Param id path string true "ID of the record to get" +// @Success 202 {object} PublishDIDRequest +// @Failure 400 {string} string "Invalid request body" +// @Failure 401 {string} string "Invalid signature" +// @Failure 409 {string} string "DID already exists with a higher sequence number" +// @Failure 500 {string} string "Internal server error" +// @Router /dids/{id} [put] +// +// TODO(gabe) support historical document storage https://github.com/TBD54566975/did-dht-method/issues/16 +func (r *GatewayRouter) PublishDID(c *gin.Context) { + id := GetParam(c, IDParam) + if id == nil || *id == "" { + LoggingRespondErrMsg(c, "missing id param", http.StatusBadRequest) + return + } + + var req PublishDIDRequest + if err := Decode(c.Request, &req); err != nil { + LoggingRespondErrWithMsg(c, err, "failed to decode request", http.StatusBadRequest) + return + } + + // three possible errors + // 1. invalid signature + // 2. did already exists with a higher sequence number + // 3. internal service error + if err := r.service.PublishDID(c, req.toServiceRequest(*id)); err != nil { + if errors.Is(err, &util.InvalidSignatureError{}) { + Respond(c, nil, http.StatusUnauthorized) + return + } + + if errors.Is(err, &util.HigherSequenceNumberError{}) { + Respond(c, nil, http.StatusConflict) + return + } + + LoggingRespondErrWithMsg(c, err, "failed to publish did", http.StatusInternalServerError) + } + + Respond(c, nil, http.StatusAccepted) +} + +// GetDIDResponse represents a response containing a DID document, types, and sequence numbers. +type GetDIDResponse struct { + DID did.Document `json:"did" validate:"required"` + Types []didint.TypeIndex `json:"types,omitempty"` + SequenceNumbers []int `json:"sequence_numbers,omitempty"` +} + +// GetDID godoc +// +// @Summary Get a DID document +// @Description Get a DID document +// @Tags DID +// @Accept json +// @Param id path string true "ID of the record to get" +// @Success 200 {object} GetDIDResponse +// @Failure 400 {string} string "Invalid request" +// @Failure 404 {string} string "DID not found" +// @Failure 500 {string} string "Internal server error" +// @Failure 501 {string} string "Historical resolution not supported by this gateway" +// @Router /dids/{id} [get] +// +// TODO(gabe) support historical queries https://github.com/TBD54566975/did-dht-method/issues/16 +func (r *GatewayRouter) GetDID(c *gin.Context) { + id := GetParam(c, IDParam) + if id == nil || *id == "" { + LoggingRespondErrMsg(c, "missing id param", http.StatusBadRequest) + return + } + + resp, err := r.service.GetDID(*id) + if err != nil { + LoggingRespondErrWithMsg(c, err, "failed to get did", http.StatusInternalServerError) + return + } + + if resp == nil { + LoggingRespondErrMsg(c, "did not found", http.StatusNotFound) + return + } + + Respond(c, GetDIDResponse(*resp), http.StatusOK) +} + +// GetTypesResponse represents a response containing a list of supported types and their names. +type GetTypesResponse struct { + Types []service.TypeMapping `json:"types,omitempty"` +} + +// GetTypes godoc +// +// @Summary Get a list of supported types +// @Description Get a list of supported types +// @Tags DID +// @Accept json +// @Success 200 {object} GetTypesResponse +// @Failure 501 {string} string "Type indexing is not supported by this gateway" +// @Router /dids/types [get] +func (r *GatewayRouter) GetTypes(c *gin.Context) { + resp := r.service.GetTypes() + if len(resp.Types) == 0 { + LoggingRespondErrMsg(c, "types not supported", http.StatusNotImplemented) + return + } + + Respond(c, GetTypesResponse{Types: resp.Types}, http.StatusOK) +} + +// GetDIDsForTypeResponse represents a response containing a list of DIDs for a given type. +type GetDIDsForTypeResponse struct { + DIDs []string `json:"dids,omitempty"` +} + +// GetDIDsForType godoc +// +// @Summary Get a list of DIDs for a given type +// @Description Get a list of DIDs for a given type +// @Tags DID +// @Accept json +// @Success 200 {object} GetDIDsForTypeResponse +// @Failure 400 {string} string "Invalid request" +// @Failure 404 {string} string "Type not found" +// @Failure 500 {string} string "Internal server error" +// @Failure 501 {string} string "Type indexing is not supported by this gateway" +// @Router /dids/types/{id} [get] +func (r *GatewayRouter) GetDIDsForType(c *gin.Context) { + id := GetParam(c, IDParam) + if id == nil || *id == "" { + LoggingRespondErrMsg(c, "missing id param", http.StatusBadRequest) + return + } + typeIndex, err := strconv.Atoi(*id) + if err != nil { + LoggingRespondErrWithMsg(c, err, "failed to convert type index to int", http.StatusBadRequest) + return + } + + resp, err := r.service.ListDIDsForType(service.ListDIDsForTypeRequest{Type: didint.TypeIndex(typeIndex)}) + if err != nil { + if errors.Is(err, &util.TypeNotFoundError{}) { + LoggingRespondErrMsg(c, "type not found", http.StatusNotFound) + return + } + + LoggingRespondErrWithMsg(c, err, "failed to get dids for type", http.StatusInternalServerError) + return + } + + Respond(c, GetDIDsForTypeResponse(*resp), http.StatusOK) +} + +// GetDifficultyResponse represents a response containing the current difficulty for the gateway's retention proof feature. +type GetDifficultyResponse struct { + Hash string `json:"hash" validate:"required"` + Difficulty int `json:"difficulty" validate:"required"` +} + +// GetDifficulty godoc +// +// @Summary Get the current difficulty for the gateway's retention proof feature +// @Description Get the current difficulty for the gateway's retention proof feature +// @Tags DID +// @Accept json +// @Success 200 {object} int +// @Failure 500 {string} string "Internal server error" +// @Failure 501 {string} string "Retention proofs are not supported by this gateway" +// @Router /difficulty [get] +func (r *GatewayRouter) GetDifficulty(c *gin.Context) { + resp, err := r.service.GetDifficulty() + if err != nil { + LoggingRespondErrWithMsg(c, err, "failed to get difficulty", http.StatusInternalServerError) + return + } + + if resp == nil { + LoggingRespondErrMsg(c, "retention proofs not supported", http.StatusNotImplemented) + return + } + + Respond(c, GetDifficultyResponse(*resp), http.StatusOK) +} diff --git a/impl/pkg/server/server.go b/impl/pkg/server/server.go index f3e1e44e..5d54a7dd 100644 --- a/impl/pkg/server/server.go +++ b/impl/pkg/server/server.go @@ -33,6 +33,17 @@ type Server struct { } // NewServer returns a new instance of Server with the given db and host. +// +// @title The DID DHT Service +// @version 0.1 +// @description The DID DHT Service +// @contact.name TBD +// @contact.url https://github.com/TBD54566975/did-dht-method +// @contact.email tbd-developer@squareup.com +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @externalDocs.description OpenAPI +// @externalDocs.url https://swagger.io/resources/open-api/ func NewServer(cfg *config.Config, shutdown chan os.Signal) (*Server, error) { // set up server prerequisites setupLogger(cfg.ServerConfig.LogLevel) @@ -47,6 +58,10 @@ func NewServer(cfg *config.Config, shutdown chan os.Signal) (*Server, error) { if err != nil { return nil, util.LoggingErrorMsg(err, "could not instantiate pkarr service") } + gatewayService, err := service.NewGatewayService(cfg, db, pkarrService) + if err != nil { + return nil, util.LoggingErrorMsg(err, "could not instantiate gateway service") + } handler.GET("/health", Health) @@ -58,6 +73,12 @@ func NewServer(cfg *config.Config, shutdown chan os.Signal) (*Server, error) { if err = PkarrAPI(&handler.RouterGroup, pkarrService); err != nil { return nil, util.LoggingErrorMsg(err, "could not setup pkarr API") } + + // gateway API + if err = GatewayAPI(&handler.RouterGroup, gatewayService); err != nil { + return nil, util.LoggingErrorMsg(err, "could not setup gateway API") + } + return &Server{ Server: &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.ServerConfig.APIHost, cfg.ServerConfig.APIPort), @@ -123,12 +144,19 @@ func PkarrAPI(rg *gin.RouterGroup, service *service.PkarrService) error { return nil } -// func GatewayAPI(rg *gin.RouterGroup, service *service.PkarrService) error { -// gatewayRouter, err := NewGatewayRouter(service) -// if err != nil { -// return util.LoggingErrorMsg(err, "could not instantiate gateway router") -// } -// -// rg.GET("/did", gatewayRouter.GetRecord) -// return nil -// } +// GatewayAPI sets up the gateway API routes according to the spec https://did-dht.com/#gateway-api +func GatewayAPI(rg *gin.RouterGroup, service *service.GatewayService) error { + gatewayRouter, err := NewGatewayRouter(service) + if err != nil { + return util.LoggingErrorMsg(err, "could not instantiate gateway router") + } + + rg.GET("/difficulty", gatewayRouter.GetDifficulty) + + didsAPI := rg.Group("/dids") + didsAPI.PUT("/:id", gatewayRouter.PublishDID) + didsAPI.GET("/:id", gatewayRouter.GetDID) + didsAPI.GET("/types", gatewayRouter.GetDIDsForType) + didsAPI.GET("/types/:id", gatewayRouter.GetDIDsForType) + return nil +} diff --git a/impl/pkg/service/gateway.go b/impl/pkg/service/gateway.go new file mode 100644 index 00000000..c9764b98 --- /dev/null +++ b/impl/pkg/service/gateway.go @@ -0,0 +1,249 @@ +package service + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + + "github.com/TBD54566975/ssi-sdk/did" + "github.com/TBD54566975/ssi-sdk/util" + "github.com/miekg/dns" + "github.com/pkg/errors" + "github.com/tv42/zbase32" + + "github.com/TBD54566975/did-dht-method/config" + didint "github.com/TBD54566975/did-dht-method/internal/did" + intutil "github.com/TBD54566975/did-dht-method/internal/util" + "github.com/TBD54566975/did-dht-method/pkg/storage" +) + +type GatewayService struct { + cfg *config.Config + db *storage.Storage + pkarr *PkarrService +} + +func NewGatewayService(cfg *config.Config, db *storage.Storage, pkarrService *PkarrService) (*GatewayService, error) { + if cfg == nil { + return nil, util.LoggingNewError("config is required") + } + if db == nil && !db.IsOpen() { + return nil, util.LoggingNewError("storage is required be non-nil and to be open") + } + if pkarrService == nil { + return nil, util.LoggingNewError("pkarr service is required") + } + return &GatewayService{ + cfg: cfg, + db: db, + pkarr: pkarrService, + }, nil +} + +type PublishDIDRequest struct { + DID string `json:"did" validate:"required"` + Sig string `json:"sig" validate:"required"` + Seq int64 `json:"seq" validate:"required"` + V string `json:"v" validate:"required"` + RetentionProof string `json:"retention_proof,omitempty"` +} + +func (p PublishDIDRequest) toPkarrRequest(suffix string) (*PublishPkarrRequest, error) { + keyBytes, err := zbase32.DecodeString(suffix) + if err != nil { + return nil, err + } + if len(keyBytes) != ed25519.PublicKeySize { + return nil, errors.New("invalid key length") + } + encoding := base64.RawURLEncoding + sigBytes, err := encoding.DecodeString(p.Sig) + if err != nil { + return nil, err + } + if len(sigBytes) != ed25519.SignatureSize { + return nil, &intutil.InvalidSignatureError{} + } + vBytes, err := encoding.DecodeString(p.V) + if err != nil { + return nil, err + } + if len(vBytes) > 1000 { + return nil, errors.New("v exceeds 1000 bytes") + } + return &PublishPkarrRequest{ + V: vBytes, + K: [32]byte(keyBytes), + Sig: [64]byte(sigBytes), + Seq: p.Seq, + }, nil +} + +func (s *GatewayService) PublishDID(ctx context.Context, req PublishDIDRequest) error { + id := didint.DHT(req.DID) + suffix, err := id.Suffix() + if err != nil { + return err + } + pkarrRequest, err := req.toPkarrRequest(suffix) + if err != nil { + return err + } + + // TODO(gabe): retention proof support https://github.com/TBD54566975/did-dht-method/issues/73 + + // unpack as a DID Document and store metadata + msg := new(dns.Msg) + if err = msg.Unpack(pkarrRequest.V); err != nil { + return errors.Wrap(err, "failed to unpack records") + } + doc, types, err := id.FromDNSPacket(msg) + if err != nil { + return errors.Wrap(err, "failed to parse DID document from DNS packet") + } + + // check to see if the DID already exists with a higher sequence number + gotDID, err := s.db.ReadDID(req.DID) + if err == nil && gotDID != nil { + if gotDID.SequenceNumber > req.Seq { + return &intutil.HigherSequenceNumberError{} + } + } + + if err = s.db.WriteDID(storage.GatewayRecord{ + Document: *doc, + Types: types, + SequenceNumber: req.Seq, + RetentionProof: req.RetentionProof, + }); err != nil { + return errors.Wrap(err, "failed to write DID document to db") + } + + // publish to the network + // TODO(gabe): check for conflicts with existing record sequence numbers https://github.com/TBD54566975/did-dht-method/issues/16 + if err = s.pkarr.PublishPkarr(ctx, suffix, *pkarrRequest); err != nil { + return err + } + + return nil +} + +type GetDIDResponse struct { + DID did.Document `json:"did" validate:"required"` + Types []didint.TypeIndex `json:"types,omitempty"` + SequenceNumbers []int `json:"sequence_numbers,omitempty"` +} + +func (s *GatewayService) GetDID(id string) (*GetDIDResponse, error) { + gotDID, err := s.db.ReadDID(id) + if err != nil { + return nil, err + } + + if gotDID == nil { + return nil, nil + } + + return &GetDIDResponse{ + DID: gotDID.Document, + Types: gotDID.Types, + SequenceNumbers: []int{int(gotDID.SequenceNumber)}, + }, nil +} + +type GetTypesResponse struct { + Types []TypeMapping `json:"types,omitempty"` +} + +type TypeMapping struct { + TypeIndex didint.TypeIndex `json:"type_index" validate:"required"` + Type string `json:"type" validate:"required"` +} + +// GetTypes returns a list of supported types and their names. +// As defined by the spec's registry https://did-dht.com/registry/index.html#indexed-types +func (s *GatewayService) GetTypes() GetTypesResponse { + return GetTypesResponse{ + Types: knownTypes, + } +} + +type ListDIDsForTypeRequest struct { + Type didint.TypeIndex `json:"type" validate:"required"` +} + +type ListDIDsForTypeResponse struct { + DIDs []string `json:"dids,omitempty"` +} + +// ListDIDsForType returns a list of DIDs for a given type. +func (s *GatewayService) ListDIDsForType(req ListDIDsForTypeRequest) (*ListDIDsForTypeResponse, error) { + if !isKnownType(req.Type) { + return nil, &intutil.TypeNotFoundError{} + } + dids, err := s.db.ListDIDsForType(req.Type) + if err != nil { + return nil, err + } + if len(dids) == 0 { + return nil, nil + } + return &ListDIDsForTypeResponse{DIDs: dids}, nil +} + +type GetDifficultyResponse struct { + Hash string `json:"hash" validate:"required"` + Difficulty int `json:"difficulty" validate:"required"` +} + +// GetDifficulty returns the current difficulty for the gateway's retention proof feature. +// TODO(gabe): retention proof support https://github.com/TBD54566975/did-dht-method/issues/73 +func (s *GatewayService) GetDifficulty() (*GetDifficultyResponse, error) { + return nil, errors.New("not yet implemented") +} + +func isKnownType(t didint.TypeIndex) bool { + for _, knownType := range knownTypes { + if knownType.TypeIndex == t { + return true + } + } + return false +} + +var ( + knownTypes = []TypeMapping{ + { + TypeIndex: didint.Discoverable, + Type: "Discoverable", + }, + { + TypeIndex: didint.Organization, + Type: "Organization", + }, + { + TypeIndex: didint.GovernmentOrganization, + Type: "Government Organization", + }, + { + TypeIndex: didint.Corporation, + Type: "Corporation", + }, + { + TypeIndex: didint.LocalBusiness, + Type: "Local Business", + }, + { + TypeIndex: didint.SoftwarePackage, + Type: "Software Package", + }, + { + TypeIndex: didint.WebApplication, + Type: "Web Application", + }, + { + TypeIndex: didint.FinancialInstitution, + Type: "Financial Institution", + }, + } +) diff --git a/impl/pkg/service/gateway_test.go b/impl/pkg/service/gateway_test.go new file mode 100644 index 00000000..5dd6fe71 --- /dev/null +++ b/impl/pkg/service/gateway_test.go @@ -0,0 +1,34 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/TBD54566975/did-dht-method/config" + "github.com/TBD54566975/did-dht-method/pkg/storage" +) + +func TestGatewayService(t *testing.T) { + svc := newGatewayService(t) + require.NotEmpty(t, svc) + + t.Run("Publish and Get a DID", func(t *testing.T) { + + }) +} + +func newGatewayService(t *testing.T) GatewayService { + defaultConfig := config.GetDefaultConfig() + db, err := storage.NewStorage(defaultConfig.ServerConfig.DBFile) + require.NoError(t, err) + require.NotEmpty(t, db) + pkarrService, err := NewPkarrService(&defaultConfig, db) + require.NoError(t, err) + require.NotEmpty(t, pkarrService) + + gatewayService, err := NewGatewayService(&defaultConfig, db, pkarrService) + require.NoError(t, err) + require.NotEmpty(t, gatewayService) + return *gatewayService +} diff --git a/impl/pkg/service/pkarr.go b/impl/pkg/service/pkarr.go index 8eeb69f2..6dcb0aab 100644 --- a/impl/pkg/service/pkarr.go +++ b/impl/pkg/service/pkarr.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/base64" - "errors" "time" "github.com/goccy/go-json" @@ -16,6 +15,7 @@ import ( "github.com/TBD54566975/did-dht-method/config" dhtint "github.com/TBD54566975/did-dht-method/internal/dht" + intutil "github.com/TBD54566975/did-dht-method/internal/util" "github.com/TBD54566975/did-dht-method/pkg/dht" "github.com/TBD54566975/did-dht-method/pkg/storage" ) @@ -87,7 +87,7 @@ func (p PublishPkarrRequest) isValid() error { return err } if !bep44.Verify(p.K[:], nil, p.Seq, bv, p.Sig[:]) { - return errors.New("signature is invalid") + return &intutil.InvalidSignatureError{} } return nil } @@ -229,6 +229,7 @@ func (s *PkarrService) addRecordToCache(id string, resp GetPkarrResponse) error } // TODO(gabe) make this more efficient. create a publish schedule based on each individual record, not all records +// TODO(gabe) consider a get before put to avoid writing outdated records https://github.com/TBD54566975/did-dht-method/issues/12 func (s *PkarrService) republish() { allRecords, err := s.db.ListRecords() if err != nil { diff --git a/impl/pkg/service/pkarr_test.go b/impl/pkg/service/pkarr_test.go index 21f31b91..3f67a853 100644 --- a/impl/pkg/service/pkarr_test.go +++ b/impl/pkg/service/pkarr_test.go @@ -13,8 +13,8 @@ import ( "github.com/TBD54566975/did-dht-method/pkg/storage" ) -func TestPKARRService(t *testing.T) { - svc := newPKARRService(t) +func TestPkarrService(t *testing.T) { + svc := newPkarrService(t) require.NotEmpty(t, svc) t.Run("test put bad record", func(t *testing.T) { @@ -63,7 +63,7 @@ func TestPKARRService(t *testing.T) { Seq: putMsg.Seq, }) assert.Error(t, err) - assert.Contains(t, err.Error(), "signature is invalid") + assert.Contains(t, err.Error(), "invalid signature") }) t.Run("test put and get record", func(t *testing.T) { @@ -100,7 +100,7 @@ func TestPKARRService(t *testing.T) { }) } -func newPKARRService(t *testing.T) PkarrService { +func newPkarrService(t *testing.T) PkarrService { defaultConfig := config.GetDefaultConfig() db, err := storage.NewStorage(defaultConfig.ServerConfig.DBFile) require.NoError(t, err) diff --git a/impl/pkg/storage/gateway.go b/impl/pkg/storage/gateway.go new file mode 100644 index 00000000..71e67b6c --- /dev/null +++ b/impl/pkg/storage/gateway.go @@ -0,0 +1,166 @@ +package storage + +import ( + "strconv" + + "github.com/TBD54566975/ssi-sdk/did" + "github.com/goccy/go-json" + + didint "github.com/TBD54566975/did-dht-method/internal/did" +) + +const ( + gatewayNamespace = "dids" + typesNamespace = "types" +) + +type GatewayRecord struct { + // TODO(gabe) when historical document storage is supported, this should be a list of documents + Document did.Document `json:"document" validate:"required"` + Types []didint.TypeIndex `json:"types,omitempty"` + SequenceNumber int64 `json:"sequence_number" validate:"required"` + RetentionProof string `json:"retention_proof,omitempty"` +} + +type TypeRecord struct { + Types []string `json:"dids,omitempty"` +} + +// WriteDID writes a DID to the storage and adds it to the type index(es) it is associated with +func (s *Storage) WriteDID(record GatewayRecord) error { + // note current types for the DID to make sure we update the appropriate indexes + gotDID, err := s.ReadDID(record.Document.ID) + var currTypes []didint.TypeIndex + if err == nil && gotDID != nil { + currTypes = gotDID.Types + } + recordBytes, err := json.Marshal(record) + if err != nil { + return err + } + if err = s.Write(gatewayNamespace, record.Document.ID, recordBytes); err != nil { + return err + } + return s.UpdateTypeIndexesForDID(record.Document.ID, currTypes, record.Types) +} + +// ReadDID reads a DID from the storage by ID +func (s *Storage) ReadDID(id string) (*GatewayRecord, error) { + recordBytes, err := s.Read(gatewayNamespace, id) + if err != nil { + return nil, err + } + if len(recordBytes) == 0 { + return nil, nil + } + var record GatewayRecord + if err = json.Unmarshal(recordBytes, &record); err != nil { + return nil, err + } + return &record, nil +} + +// UpdateTypeIndexesForDID is an orchestration method that updates the type indexes for a DID +// It checks the existing type indexes for the DID and adds/removes the DID from the appropriate type indexes +func (s *Storage) UpdateTypeIndexesForDID(id string, currTypes, newTypes []didint.TypeIndex) error { + currTypeMap := make(map[didint.TypeIndex]bool) + for _, currType := range currTypes { + currTypeMap[currType] = true + } + newTypeMap := make(map[didint.TypeIndex]bool) + for _, newType := range newTypes { + newTypeMap[newType] = true + } + + // remove the DID from any type indexes it is no longer associated with + for _, currType := range currTypes { + if _, ok := newTypeMap[currType]; !ok { + if err := s.RemoveDIDFromTypeIndex(id, currType); err != nil { + return err + } + } + } + + // add the DID to any type indexes it is now associated with + for _, newType := range newTypes { + if _, ok := currTypeMap[newType]; !ok { + if err := s.AddDIDToTypeIndex(id, newType); err != nil { + return err + } + } + } + + return nil +} + +// AddDIDToTypeIndex adds a DID to a type index by appending it to the list of DIDs for that type index +// If the type index does not exist, it is created and the DID is added to it +func (s *Storage) AddDIDToTypeIndex(id string, typeIndex didint.TypeIndex) error { + t := strconv.Itoa(int(typeIndex)) + recordBytes, err := s.Read(typesNamespace, t) + if err != nil { + return err + } + if len(recordBytes) == 0 { + record := TypeRecord{Types: []string{id}} + recordBytes, err = json.Marshal(record) + if err != nil { + return err + } + return s.Write(typesNamespace, t, recordBytes) + } + var record TypeRecord + if err = json.Unmarshal(recordBytes, &record); err != nil { + return err + } + record.Types = append(record.Types, id) + recordBytes, err = json.Marshal(record) + if err != nil { + return err + } + return s.Write(typesNamespace, t, recordBytes) +} + +// RemoveDIDFromTypeIndex removes a DID from a type index by removing it from the list of DIDs for that type index +func (s *Storage) RemoveDIDFromTypeIndex(id string, typeIndex didint.TypeIndex) error { + t := strconv.Itoa(int(typeIndex)) + recordBytes, err := s.Read(typesNamespace, t) + if err != nil { + return err + } + if len(recordBytes) == 0 { + return nil + } + var record TypeRecord + if err = json.Unmarshal(recordBytes, &record); err != nil { + return err + } + for i, didID := range record.Types { + if didID == id { + record.Types = append(record.Types[:i], record.Types[i+1:]...) + break + } + } + recordBytes, err = json.Marshal(record) + if err != nil { + return err + } + return s.Write(typesNamespace, t, recordBytes) +} + +// ListDIDsForType returns a list of DIDs for a given type index +func (s *Storage) ListDIDsForType(typeIndex didint.TypeIndex) ([]string, error) { + t := strconv.Itoa(int(typeIndex)) + recordBytes, err := s.Read(typesNamespace, t) + if err != nil { + return nil, err + } + if len(recordBytes) == 0 { + return nil, nil + } + var record TypeRecord + if err = json.Unmarshal(recordBytes, &record); err != nil { + return nil, err + } + return record.Types, nil +} diff --git a/impl/pkg/storage/gateway_test.go b/impl/pkg/storage/gateway_test.go new file mode 100644 index 00000000..d0d411e8 --- /dev/null +++ b/impl/pkg/storage/gateway_test.go @@ -0,0 +1,161 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/TBD54566975/did-dht-method/internal/did" +) + +func TestGatewayStorage(t *testing.T) { + t.Run("Read and Write DID", func(t *testing.T) { + db := setupBoltDB(t) + defer db.Close() + require.NotEmpty(t, db) + + // create a did doc to store + _, doc, err := did.GenerateDIDDHT(did.CreateDIDDHTOpts{}) + require.NoError(t, err) + require.NotEmpty(t, doc) + + // create record + record := GatewayRecord{ + Document: *doc, + Types: []did.TypeIndex{1, 2, 3}, + SequenceNumber: 1, + } + + err = db.WriteDID(record) + assert.NoError(t, err) + + // get it back + readRecord, err := db.ReadDID(record.Document.ID) + assert.NoError(t, err) + assert.Equal(t, record, *readRecord) + }) + + t.Run("Update a DID and its type indexes", func(t *testing.T) { + db := setupBoltDB(t) + defer db.Close() + require.NotEmpty(t, db) + + // create a did doc to store + _, doc, err := did.GenerateDIDDHT(did.CreateDIDDHTOpts{}) + require.NoError(t, err) + require.NotEmpty(t, doc) + + // create record + record := GatewayRecord{ + Document: *doc, + Types: []did.TypeIndex{1, 2, 3}, + SequenceNumber: 1, + } + + err = db.WriteDID(record) + assert.NoError(t, err) + + // get types + types, err := db.ListDIDsForType(1) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + types, err = db.ListDIDsForType(2) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + types, err = db.ListDIDsForType(3) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + // update record + record.Types = []did.TypeIndex{4, 5, 6} + record.SequenceNumber = 2 + err = db.WriteDID(record) + assert.NoError(t, err) + + // get it back + readRecord, err := db.ReadDID(record.Document.ID) + assert.NoError(t, err) + assert.Equal(t, record, *readRecord) + + // get types + types, err = db.ListDIDsForType(1) + assert.NoError(t, err) + assert.Empty(t, types) + + types, err = db.ListDIDsForType(2) + assert.NoError(t, err) + assert.Empty(t, types) + + types, err = db.ListDIDsForType(3) + assert.NoError(t, err) + assert.Empty(t, types) + + types, err = db.ListDIDsForType(4) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + types, err = db.ListDIDsForType(5) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + types, err = db.ListDIDsForType(6) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + }) + + t.Run("Multiple DIDs with Types", func(t *testing.T) { + db := setupBoltDB(t) + defer db.Close() + require.NotEmpty(t, db) + + // create a did doc to store + _, doc, err := did.GenerateDIDDHT(did.CreateDIDDHTOpts{}) + require.NoError(t, err) + require.NotEmpty(t, doc) + + // create record + record := GatewayRecord{ + Document: *doc, + Types: []did.TypeIndex{1, 2, 3}, + SequenceNumber: 1, + } + + err = db.WriteDID(record) + assert.NoError(t, err) + + // create a did doc to store + _, doc2, err := did.GenerateDIDDHT(did.CreateDIDDHTOpts{}) + require.NoError(t, err) + require.NotEmpty(t, doc) + + // create record + record2 := GatewayRecord{ + Document: *doc2, + Types: []did.TypeIndex{2, 3, 4}, + SequenceNumber: 1, + } + + err = db.WriteDID(record2) + assert.NoError(t, err) + + // get types + types, err := db.ListDIDsForType(1) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID}, types) + + types, err = db.ListDIDsForType(2) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID, record2.Document.ID}, types) + + types, err = db.ListDIDsForType(3) + assert.NoError(t, err) + assert.Equal(t, []string{record.Document.ID, record2.Document.ID}, types) + + types, err = db.ListDIDsForType(4) + assert.NoError(t, err) + assert.Equal(t, []string{record2.Document.ID}, types) + }) +}