diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -143,11 +143,11 @@ Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that. ### Identity -- [ ] `com.atproto.identity.getRecommendedDidCredentials` -- [ ] `com.atproto.identity.requestPlcOperationSignature` +- [x] `com.atproto.identity.getRecommendedDidCredentials` +- [x] `com.atproto.identity.requestPlcOperationSignature` - [x] `com.atproto.identity.resolveHandle` -- [ ] `com.atproto.identity.signPlcOperation` -- [ ] `com.atproto.identity.submitPlcOperation` +- [x] `com.atproto.identity.signPlcOperation` +- [x] `com.atproto.identity.submitPlcOperation` - [x] `com.atproto.identity.updateHandle` ### Repo @@ -158,7 +158,7 @@ - [x] `com.atproto.repo.putRecord` - [x] `com.atproto.repo.deleteRecord` - [x] `com.atproto.repo.describeRepo` - [x] `com.atproto.repo.getRecord` -- [x] `com.atproto.repo.importRepo` (Works "okay". You still have to handle PLC operations on your own when migrating. Use with extreme caution.) +- [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) - [x] `com.atproto.repo.listRecords` - [ ] `com.atproto.repo.listMissingBlobs` diff --git a/models/models.go b/models/models.go --- a/models/models.go +++ b/models/models.go @@ -19,6 +19,8 @@ EmailUpdateCode *string EmailUpdateCodeExpiresAt *time.Time PasswordResetCode *string PasswordResetCodeExpiresAt *time.Time + PlcOperationCode *string + PlcOperationCodeExpiresAt *time.Time Password string SigningKey []byte Rev string diff --git a/server/handle_identity_request_plc_operation.go b/server/handle_identity_request_plc_operation.go new file mode 100644 --- /dev/null +++ b/server/handle_identity_request_plc_operation.go @@ -0,0 +1,29 @@ +package server + +import ( + "fmt" + "time" + + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/labstack/echo/v4" +) + +func (s *Server) handleIdentityRequestPlcOperationSignature(e echo.Context) error { + urepo := e.Get("repo").(*models.RepoActor) + + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) + eat := time.Now().Add(10 * time.Minute).UTC() + + if err := s.db.Exec("UPDATE repos SET plc_operation_code = ?, plc_operation_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { + s.logger.Error("error updating user", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.sendPlcTokenReset(urepo.Email, urepo.Handle, code); err != nil { + s.logger.Error("error sending mail", "error", err) + return helpers.ServerError(e, nil) + } + + return e.NoContent(200) +} diff --git a/server/handle_identity_sign_plc_operation.go b/server/handle_identity_sign_plc_operation.go new file mode 100644 --- /dev/null +++ b/server/handle_identity_sign_plc_operation.go @@ -0,0 +1,103 @@ +package server + +import ( + "context" + "strings" + "time" + + "github.com/Azure/go-autorest/autorest/to" + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/haileyok/cocoon/identity" + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/models" + "github.com/haileyok/cocoon/plc" + "github.com/labstack/echo/v4" +) + +type ComAtprotoSignPlcOperationRequest struct { + Token string `json:"token"` + VerificationMethods *map[string]string `json:"verificationMethods"` + RotationKeys *[]string `json:"rotationKeys"` + AlsoKnownAs *[]string `json:"alsoKnownAs"` + Services *map[string]identity.OperationService `json:"services"` +} + +type ComAtprotoSignPlcOperationResponse struct { + Operation plc.Operation `json:"operation"` +} + +func (s *Server) handleSignPlcOperation(e echo.Context) error { + repo := e.Get("repo").(*models.RepoActor) + + var req ComAtprotoSignPlcOperationRequest + if err := e.Bind(&req); err != nil { + s.logger.Error("error binding", "error", err) + return helpers.ServerError(e, nil) + } + + if !strings.HasPrefix(repo.Repo.Did, "did:plc:") { + return helpers.InputError(e, nil) + } + + if repo.PlcOperationCode == nil || repo.PlcOperationCodeExpiresAt == nil { + return helpers.InputError(e, to.StringPtr("InvalidToken")) + } + + if *repo.PlcOperationCode != req.Token { + return helpers.InvalidTokenError(e) + } + + if time.Now().UTC().After(*repo.PlcOperationCodeExpiresAt) { + return helpers.ExpiredTokenError(e) + } + + ctx := context.WithValue(e.Request().Context(), "skip-cache", true) + log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) + if err != nil { + s.logger.Error("error fetching doc", "error", err) + return helpers.ServerError(e, nil) + } + + latest := log[len(log)-1] + + op := plc.Operation{ + Type: "plc_operation", + VerificationMethods: latest.Operation.VerificationMethods, + RotationKeys: latest.Operation.RotationKeys, + AlsoKnownAs: latest.Operation.AlsoKnownAs, + Services: latest.Operation.Services, + Prev: &latest.Cid, + } + if req.VerificationMethods != nil { + op.VerificationMethods = *req.VerificationMethods + } + if req.RotationKeys != nil { + op.RotationKeys = *req.RotationKeys + } + if req.AlsoKnownAs != nil { + op.AlsoKnownAs = *req.AlsoKnownAs + } + if req.Services != nil { + op.Services = *req.Services + } + + k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) + if err != nil { + s.logger.Error("error parsing signing key", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.plcClient.SignOp(k, &op); err != nil { + s.logger.Error("error signing plc operation", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.db.Exec("UPDATE repos SET plc_operation_code = NULL, plc_operation_code_expires_at = NULL WHERE did = ?", nil, repo.Repo.Did).Error; err != nil { + s.logger.Error("error updating repo", "error", err) + return helpers.ServerError(e, nil) + } + + return e.JSON(200, ComAtprotoSignPlcOperationResponse{ + Operation: op, + }) +} diff --git a/server/mail.go b/server/mail.go --- a/server/mail.go +++ b/server/mail.go @@ -40,6 +40,25 @@ return nil } +func (s *Server) sendPlcTokenReset(email, handle, code string) error { + if s.mail == nil { + return nil + } + + s.mailLk.Lock() + defer s.mailLk.Unlock() + + s.mail.To(email) + s.mail.Subject("PLC token for " + s.config.Hostname) + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your PLC operation code is %s. This code will expire in ten minutes.", handle, code)) + + if err := s.mail.Send(); err != nil { + return err + } + + return nil +} + func (s *Server) sendEmailUpdate(email, handle, code string) error { if s.mail == nil { return nil diff --git a/server/server.go b/server/server.go --- a/server/server.go +++ b/server/server.go @@ -461,6 +461,8 @@ s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.GET("/xrpc/com.atproto.identity.getRecommendedDidCredentials", s.handleGetRecommendedDidCredentials, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.identity.requestPlcOperationSignature", s.handleIdentityRequestPlcOperationSignature, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) + s.echo.POST("/xrpc/com.atproto.identity.signPlcOperation", s.handleSignPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.POST("/xrpc/com.atproto.identity.submitPlcOperation", s.handleSubmitPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)