From 88812ec865c85ae027a14a643bfd1e30d5712a5f8ff02c828d9c98d67b5961d9 Mon Sep 17 00:00:00 2001 From: Daan Selen Date: Tue, 28 Apr 2026 23:04:15 +0200 Subject: [PATCH] feat: add key creation --- go.mod | 2 +- internal/server/api/routes/api.go | 89 +++++++++++++------ internal/server/api/routes/file.go | 84 +++++++++++------ internal/server/api/routes/types.go | 9 ++ internal/server/bootstrap/types.go | 5 +- internal/server/database/database.go | 11 +-- internal/server/database/functions.go | 4 +- internal/server/database/types.go | 52 ++++++----- internal/server/database/utility.go | 26 ++++-- internal/server/watchdog/executor.go | 2 +- internal/shared/security/generator.go | 24 +++++ internal/shared/security/hash.go | 71 +++++++++++++++ internal/shared/utility/generator.go | 25 ------ .../shared/utility/{define.go => types.go} | 0 14 files changed, 289 insertions(+), 115 deletions(-) create mode 100644 internal/server/api/routes/types.go create mode 100644 internal/shared/security/generator.go create mode 100644 internal/shared/security/hash.go delete mode 100644 internal/shared/utility/generator.go rename internal/shared/utility/{define.go => types.go} (100%) diff --git a/go.mod b/go.mod index a904a3e..9a55e87 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 github.com/spf13/pflag v1.0.10 + golang.org/x/crypto v0.48.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -38,7 +39,6 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/internal/server/api/routes/api.go b/internal/server/api/routes/api.go index 1c0576c..5e3c5bf 100644 --- a/internal/server/api/routes/api.go +++ b/internal/server/api/routes/api.go @@ -5,28 +5,82 @@ import ( "net/http" "orbits-server/internal/server/api/response" "orbits-server/internal/server/database" + "orbits-server/internal/shared/security" "github.com/gin-gonic/gin" "gorm.io/gorm" ) -func RegisterApiRoutes(api *gin.RouterGroup /* env runtime.Environment,*/, db *gorm.DB) { +const ( + accessKeyLen = 32 +) + +func RegisterApiRoutes(api *gin.RouterGroup, db *gorm.DB) { // prefix: api - api.GET("/keys", func(c *gin.Context) { + // define subroute with key + // /api/key + key := api.Group("/key") + + /* + key.GET("/:key", func(c *gin.Context) { + + }) + */ + + key.POST("/create", func(c *gin.Context) { + var body keyRequestBody + + err := c.ShouldBindBodyWithJSON(&body) + if err != nil { + slog.Error("failed to bind body to json", "error", err) + c.JSON(http.StatusBadRequest, response.BasicResponse{ + Msg: "invalid JSON", + }) + return + } + + keyContent := security.GenerateChars(accessKeyLen) + hash, err := security.HashKey(keyContent) + if err != nil { + slog.Error("failed to generate a hash for the key", "error", err) + c.JSON(http.StatusInternalServerError, response.BasicResponse{ + Msg: response.IntErrMes, + }) + return + } + + keyRecord := database.BuildKeyRecord(hash, body.Name, body.ExpiresAt) + + if err := database.CreateKey(db, &keyRecord); err != nil { + slog.Error("failed to insert key into the database", "error", err) + c.JSON(http.StatusInternalServerError, response.BasicResponse{ + Msg: response.IntErrMes, + }) + return + } + + slog.Info("saved key to database") + + c.JSON(http.StatusCreated, response.BasicResponse{ + Msg: "key has succesfully been created and saved", + Data: keyContent, + }) + }) + + key.GET("/verify", func(c *gin.Context) { }) - api.POST("/keys", func(c *gin.Context) { - - }) - - api.DELETE("/keys", func(c *gin.Context) { + key.DELETE("/:key", func(c *gin.Context) { }) + // define the control route on the api + // /api/control + ctl := api.Group("/control") // Display the information on what is going on at the moment - api.GET("/command", func(c *gin.Context) { + ctl.GET("/command", func(c *gin.Context) { state, err := database.LatestState(db) if err != nil { slog.Error("unable to determine state", "error", err) @@ -42,24 +96,7 @@ func RegisterApiRoutes(api *gin.RouterGroup /* env runtime.Environment,*/, db *g }) }) - api.PATCH("/command", func(c *gin.Context) { + ctl.PATCH("/command", func(c *gin.Context) { }) - - // define a route to check what is registered - api.GET("/available", func(c *gin.Context) { - files, err := database.ListFiles(db) - if err != nil { - slog.Error("failed to retrieve available files", "error", err) - c.JSON(http.StatusInternalServerError, response.BasicResponse{ - Msg: response.IntErrMes, - }) - return - } - - c.JSON(http.StatusOK, response.BasicResponse{ - Msg: response.OkMes, - Data: files, - }) - }) } diff --git a/internal/server/api/routes/file.go b/internal/server/api/routes/file.go index 0213829..305fb27 100644 --- a/internal/server/api/routes/file.go +++ b/internal/server/api/routes/file.go @@ -16,6 +16,8 @@ import ( func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *gorm.DB) { // prefix: file // for example: /file/ + + // file download route / display contents file.GET("/:filename", func(c *gin.Context) { fileParam := c.Param("filename") p := filepath.Join(env.ContentDirectory, fileParam) @@ -23,8 +25,7 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go c.File(p) }) - // define the upload route - // /file/upload + // upload route file.POST("/upload", func(c *gin.Context) { f, err := c.FormFile("file") if err != nil { @@ -38,10 +39,14 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go readerStream, err := f.Open() if err != nil { slog.Error("failed to a reader stream") + c.JSON(http.StatusInternalServerError, response.BasicResponse{ + Msg: response.IntErrMes, + }) + return } defer readerStream.Close() - fileData, err := database.BuildFileRecord(readerStream, f.Filename, env.ContentDirectory) + fileRecord, err := database.BuildFileRecord(readerStream, f.Filename, env.ContentDirectory) if err != nil { slog.Error("failed to enroll file to the database", "error", err) c.JSON(http.StatusInternalServerError, response.BasicResponse{ @@ -50,13 +55,14 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go return } - if err := database.CreateFile(db, fileData); err != nil { + if err := database.CreateFile(db, &fileRecord); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { - slog.Debug("discarding file since its checksum is a duplicate", "error", err) + slog.Debug("discarding file, its a checksum duplicate", "error", err) c.JSON(http.StatusConflict, response.BasicResponse{ - Msg: "file checksum already exists", + Msg: "file already exists", }) } else { + // log the failure to the std slog.Error("failed to insert filedata to the database", "error", err) c.JSON(http.StatusInternalServerError, response.BasicResponse{ Msg: response.IntErrMes, @@ -66,32 +72,58 @@ func RegisterFileRoutes(file *gin.RouterGroup, env bootstrap.Environment, db *go } // save to filesystem after everything has given a green light - if err := c.SaveUploadedFile(f, fileData.FilePath); err != nil { - slog.Error("failed to receive the file over http:", "error", err) + if err := c.SaveUploadedFile(f, fileRecord.FilePath); err != nil { + slog.Error("failed to save to disk, rolling back database", "error", err) + + // rollback db if the write has failed + err = database.DeleteFileByID(db, fileRecord.ID) + if err != nil { + slog.Error("failed to remove the database record", "error", err) + } + + // give the response to the client + c.JSON(http.StatusInternalServerError, response.BasicResponse{ + Msg: response.IntErrMes, + }) + return + } + slog.Info("saved file to local filesystem and database") + + c.JSON(http.StatusCreated, response.BasicResponse{ + Msg: "file has succesfully been uploaded", + Data: fileRecord, + }) + }) + + // delete route + file.DELETE("/:filename", func(c *gin.Context) { + fileParam := c.Param("filename") + fileRecord, err := database.FindFileByName(db, fileParam) + if err != nil { + slog.Error("file not found", "error", err) + c.JSON(http.StatusNotFound, response.BasicResponse{ + Msg: "file was not found", + }) + return + } + + slog.Info("received a delete request for a file", "file", fileRecord, "filename", fileParam) + }) + + // define a route to check what is registered + file.GET("/available", func(c *gin.Context) { + files, err := database.ListFiles(db) + if err != nil { + slog.Error("failed to retrieve available files", "error", err) c.JSON(http.StatusInternalServerError, response.BasicResponse{ Msg: response.IntErrMes, }) return } - slog.Info("saved file to local filesystem and database") - c.JSON(http.StatusCreated, response.BasicResponse{ - Msg: "file has succesfully been uploaded", - Data: fileData, + c.JSON(http.StatusOK, response.BasicResponse{ + Msg: response.OkMes, + Data: files, }) }) - - file.DELETE("/:filename", func(c *gin.Context) { - fileParam := c.Param("filename") - f, err := database.FindFileByName(db, fileParam) - - slog.Info("received a detelte request for a file", "file", f, "filename", fileParam) - - if err != nil { - slog.Error("failed to filter the file database for the name") - c.JSON(http.StatusNotFound, response.BasicResponse{ - Msg: response.IntErrMes, - }) - } - }) } diff --git a/internal/server/api/routes/types.go b/internal/server/api/routes/types.go new file mode 100644 index 0000000..647ad3e --- /dev/null +++ b/internal/server/api/routes/types.go @@ -0,0 +1,9 @@ +package routes + +import "time" + +type keyRequestBody struct { + Name string `json:"name"` + // post request must contain valid: RFC3339 timestamp + ExpiresAt time.Time `json:"expiresAt"` +} diff --git a/internal/server/bootstrap/types.go b/internal/server/bootstrap/types.go index 91917f7..c88106c 100644 --- a/internal/server/bootstrap/types.go +++ b/internal/server/bootstrap/types.go @@ -3,14 +3,15 @@ package bootstrap type Environment struct { Version string `env:"VERSION" default:"0.0.1" flag:"version" usage:"option to specify a custom version"` Codename string `env:"CODENAME" default:"Magical Anomaly" flag:"codename" usage:"option to change the release codename"` - LogLevel string `env:"LOG_LEVEL" default:"debug" flag:"log-level" usage:"option to change the loglevel"` DataDirectory string `env:"DATA_DIR" default:"./data" flag:"data-dir" usage:"option to specify where the state data gets stored"` ContentDirectory string `env:"CONTENT_DIR" default:"./content" flag:"content-dir" usage:"option to specify where the content gets stored"` Hostname string `env:"HOSTNAME" default:"0.0.0.0" flag:"hostname" usage:"option to specify the address/hostname to bind the api server to"` Port int `env:"PORT" default:"8080" flag:"port" usage:"option to specify the port to bind the api server to"` Authentication bool `env:"AUTHENTICATION" default:"true" flag:"authentication" usage:"option to disable authentication"` - AdminKey string `env:"ADMIN_KEY" default:"" flag:"admin-key" usage:"option to specify a custom admin top-level authentication key"` + LogLevel string `env:"LOG_LEVEL" default:"debug" flag:"log-level" usage:"option to change the loglevel"` + + AdminKey string `env:"ADMIN_KEY" default:"" flag:"admin-key" usage:"option to specify a custom admin top-level authentication key"` Watchdog bool `env:"WATCHDOG" default:"true" flag:"watchdog" usage:"option to disable watchdog"` WatchdogInterval int `env:"WATCHDOG_INTERVAL" default:"60" flag:"watchdog-interval" usage:"option to specify the interval in second(s) on which watchdog runs"` diff --git a/internal/server/database/database.go b/internal/server/database/database.go index 11ebc19..6bc41dc 100644 --- a/internal/server/database/database.go +++ b/internal/server/database/database.go @@ -23,11 +23,12 @@ func Kickoff(workDir string) (*gorm.DB, error) { // try to use GORM automigrate if the schema changes if err := db.AutoMigrate( - &Command{}, // app state and command status - &File{}, // files database for keeping track - &Tenant{}, // table for tenants and its data - &Group{}, // group table for privileges - &Device{}, // devices table + &AccessKey{}, // api keys for authentication + &Command{}, // app state and command status + &File{}, // files database for keeping track + &Tenant{}, // table for tenants and its data + &Group{}, // group table for privileges + &Device{}, // devices table ); err != nil { return nil, err } diff --git a/internal/server/database/functions.go b/internal/server/database/functions.go index 4989604..85904d9 100644 --- a/internal/server/database/functions.go +++ b/internal/server/database/functions.go @@ -30,7 +30,7 @@ func ListKeys(db *gorm.DB) ([]AccessKey, error) { return keys, err } -func CreateKey(db *gorm.DB, k AccessKey) error { +func CreateKey(db *gorm.DB, k *AccessKey) error { return db.Create(&k).Error } @@ -70,7 +70,7 @@ func FindFileByName(db *gorm.DB, name string) (File, error) { return file, err } -func CreateFile(db *gorm.DB, f File) error { +func CreateFile(db *gorm.DB, f *File) error { return db.Create(&f).Error } diff --git a/internal/server/database/types.go b/internal/server/database/types.go index c749a27..220edac 100644 --- a/internal/server/database/types.go +++ b/internal/server/database/types.go @@ -5,12 +5,6 @@ import ( "time" ) -type Timestamps struct { - CreatedAt time.Time `gorm:"not null;"` - UpdatedAt time.Time `gorm:"not null;"` - ExpiresAt time.Time -} - type Command struct { ID int `gorm:"primaryKey;not null;"` State string @@ -24,26 +18,34 @@ type Command struct { Targets []string `gorm:"type:json;"` // Must be the location where the file is downloadable on the API // can be none when there is no media specified (init stage) - Location string - Timestamps + Location string + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } type AccessKey struct { ID int `gorm:"primaryKey;not null;"` MetaName string - KeyName string `gorm:"not null;"` + // UUID for safe storage + KeyName string `gorm:"not null;"` // We don't store the key itself, we hash the key KeyHash string `gorm:"uniqueIndex;not null;"` - // we're cooking without pepper - Timestamps + // revoked status + Revoked bool + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } type Tenant struct { ID int `gorm:"primaryKey;not null;"` TenantName string `gorm:"not null"` TenantDescription string - Groups []Group `gorm:"foreignKey:TenantID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - Timestamps + Groups []Group `gorm:"foreignKey:TenantID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } type Group struct { @@ -51,8 +53,10 @@ type Group struct { TenantID uint `gorm:"not null;index"` GroupName string `gorm:"not null;"` GroupDescription string - Devices []Device `gorm:"foreignKey:GroupID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - Timestamps + Devices []Device `gorm:"foreignKey:GroupID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } type Device struct { @@ -61,11 +65,13 @@ type Device struct { // Device type is meant as a field where can be specified what type of device this is // eg Raspberry Pi, PC, things like that DeviceType string - Hostname string `gorm:"not null;"` - RemoteAddress string `gorm:"not null;"` - Alive bool `gorm:"not null;"` - Compliant bool `gorm:"not null;"` - Timestamps + Hostname string `gorm:"not null;"` + RemoteAddress string `gorm:"not null;"` + Alive bool `gorm:"not null;"` + Compliant bool `gorm:"not null;"` + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } type File struct { @@ -80,6 +86,8 @@ type File struct { FileName string `gorm:"not null;"` FilePath string `gorm:"not null;"` // hex encoded sha512 checksum - Checksum string `gorm:"uniqueIndex;not null;"` - Timestamps + Checksum string `gorm:"uniqueIndex;not null;"` + CreatedAt time.Time `gorm:"not null;"` + UpdatedAt time.Time `gorm:"not null;"` + ExpiresAt time.Time } diff --git a/internal/server/database/utility.go b/internal/server/database/utility.go index b4dbd44..cc51a19 100644 --- a/internal/server/database/utility.go +++ b/internal/server/database/utility.go @@ -4,31 +4,33 @@ import ( "fmt" "io" "log/slog" + "orbits-server/internal/shared/security" "orbits-server/internal/shared/utility" "path/filepath" + "time" ) // it has been made more general for DRY purposes // this function should only be called after manually checking the filetype -func BuildFileRecord(r io.Reader, origName string, contentDirectory string) (File, error) { - ext := filepath.Ext(origName) +func BuildFileRecord(r io.Reader, metaName string, contentDirectory string) (File, error) { + ext := filepath.Ext(metaName) category := utility.CategorizeMediaType(ext) if category == utility.Unspecified { return File{}, fmt.Errorf("unsupported filetype") } - checksum, err := utility.GenerateHashFromReader(r) + checksum, err := security.HashFileReader(r) if err != nil { slog.Error("failed to calculate hash of file at given path", "error", err) return File{}, err } - safeName := utility.GenerateSafeName(category, ext) + safeName := security.GenerateSafeCategoryName(category, ext) destPath := filepath.Join(contentDirectory, safeName) f := File{ MediaType: category, - MetaName: origName, + MetaName: metaName, FileName: safeName, FilePath: destPath, Checksum: checksum, @@ -36,3 +38,17 @@ func BuildFileRecord(r io.Reader, origName string, contentDirectory string) (Fil return f, nil } + +func BuildKeyRecord(keyHash string, metaName string, expiresAt time.Time) AccessKey { + safeName := security.GenerateSafeName() + + k := AccessKey{ + MetaName: metaName, + KeyName: safeName, + KeyHash: keyHash, + Revoked: false, + ExpiresAt: expiresAt, + } + + return k +} diff --git a/internal/server/watchdog/executor.go b/internal/server/watchdog/executor.go index 910569d..be37b8d 100644 --- a/internal/server/watchdog/executor.go +++ b/internal/server/watchdog/executor.go @@ -34,7 +34,7 @@ func applyFS(env bootstrap.Environment, db *gorm.DB, fsOrphans []string) { return } - database.CreateFile(db, fileData) + database.CreateFile(db, &fileData) os.Rename(fp, fileData.FilePath) }() diff --git a/internal/shared/security/generator.go b/internal/shared/security/generator.go new file mode 100644 index 0000000..fb32302 --- /dev/null +++ b/internal/shared/security/generator.go @@ -0,0 +1,24 @@ +package security + +import ( + "crypto/rand" + "encoding/base64" + "orbits-server/internal/shared/utility" + + "github.com/google/uuid" +) + +func GenerateSafeCategoryName(category utility.MediaType, ext string) string { + return uuid.New().String() + "_" + string(category) + ext +} + +func GenerateSafeName() string { + return uuid.New().String() +} + +func GenerateChars(byteLen int) string { + b := make([]byte, byteLen) + rand.Read(b) + + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/shared/security/hash.go b/internal/shared/security/hash.go new file mode 100644 index 0000000..f026eeb --- /dev/null +++ b/internal/shared/security/hash.go @@ -0,0 +1,71 @@ +package security + +import ( + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 2 + argonSaltLen = 16 + argonKeyLen = 64 +) + +func HashFileReader(r io.Reader) (string, error) { + h := sha512.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + + // return in b64 encoding + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} + +func HashKey(key string) (string, error) { + salt := make([]byte, argonSaltLen) + rand.Read(salt) + + hash := argon2.IDKey([]byte(key), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + // encode internally (NOT in handler) + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + // single stored string + encoded := fmt.Sprintf("v1:%s:%s", b64Salt, b64Hash) + + return encoded, nil +} + +func CompareKey(key, candidate string) bool { + parts := strings.Split(candidate, ":") + if len(parts) != 3 { + return false + } + //version := parts[0] + b64Salt := parts[1] + b64Hash := parts[2] + + salt, err := base64.RawStdEncoding.DecodeString(b64Salt) + if err != nil { + return false + } + + expected, err := base64.RawStdEncoding.DecodeString(b64Hash) + if err != nil { + return false + } + + actual := argon2.IDKey([]byte(key), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + return subtle.ConstantTimeCompare(actual, expected) == 1 +} diff --git a/internal/shared/utility/generator.go b/internal/shared/utility/generator.go deleted file mode 100644 index c54cad9..0000000 --- a/internal/shared/utility/generator.go +++ /dev/null @@ -1,25 +0,0 @@ -package utility - -import ( - "crypto/sha512" - "encoding/hex" - "io" - - "github.com/google/uuid" -) - -func GenerateHashFromReader(r io.Reader) (string, error) { - h := sha512.New() - if _, err := io.Copy(h, r); err != nil { - return "", err - } - - // return the sha checksum in hex - return hex.EncodeToString(h.Sum(nil)), nil - // alternatively return in base64 - //return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil -} - -func GenerateSafeName(category MediaType, ext string) string { - return uuid.New().String() + "_" + string(category) + ext -} diff --git a/internal/shared/utility/define.go b/internal/shared/utility/types.go similarity index 100% rename from internal/shared/utility/define.go rename to internal/shared/utility/types.go