Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions server/controllers/e2ee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package controllers

import (
"encoding/json"
"net/http"

"github.com/commandlinecoding/elephant/server/middlewares"
"github.com/commandlinecoding/elephant/server/models"
"github.com/commandlinecoding/elephant/server/services"
"github.com/go-chi/chi/v5"
)

func HandleUploadE2EEKeys(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
uid, _ := r.Context().Value(middlewares.UserIDKey).(string)

var req models.UploadKeysReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Malformatted JSON configuration payload structure"})
return
}

if req.DeviceID == "" {
req.DeviceID = "main"
}

svc := services.NewE2EEService()
if err := svc.UploadKeys(r.Context(), uid, req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()})
return
}

_, lowKeys, _ := svc.CheckKeysExhaustion(r.Context(), uid, req.DeviceID)

w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(models.JSONResponse{
Success: true,
Data: map[string]interface{}{
"message": "Prekey cryptographical parameters saved successfully",
"requires_refill": lowKeys,
},
})
}

func HandleGetPrekeyBundle(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
targetUID := chi.URLParam(r, "userId")
deviceID := r.URL.Query().Get("device_id")

if targetUID == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Target Identity validation token reference required"})
return
}

if deviceID == "" {
deviceID = "main"
}

svc := services.NewE2EEService()
bundle, err := svc.GetBundle(r.Context(), targetUID, deviceID)
if err != nil {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()})
return
}

_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: bundle})
}
20 changes: 20 additions & 0 deletions server/migrations/010_create_e2ee_keys_up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS user_devices (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
device_id VARCHAR(50) NOT NULL,
identity_key TEXT NOT NULL,
signed_prekey TEXT NOT NULL,
signed_prekey_signature TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, device_id)
);

CREATE TABLE IF NOT EXISTS one_time_prekeys (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
device_id VARCHAR(50) NOT NULL,
key_id INT NOT NULL,
key_content TEXT NOT NULL,
FOREIGN KEY (user_id, device_id) REFERENCES user_devices(user_id, device_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_otp_lookup ON one_time_prekeys(user_id, device_id);
24 changes: 24 additions & 0 deletions server/models/e2ee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package models

type OneTimeKeyReq struct {
ID int `json:"id"`
Content string `json:"content"`
}

type UploadKeysReq struct {
DeviceID string `json:"device_id"`
IdentityKey string `json:"identity_key"`
SignedPrekey string `json:"signed_prekey"`
Signature string `json:"signature"`
OneTimePrekeys []OneTimeKeyReq `json:"one_time_prekeys"`
}

type PrekeyBundle struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
IdentityKey string `json:"identity_key"`
SignedPrekey string `json:"signed_prekey"`
Signature string `json:"signature"`
OneTimePrekeyID *int `json:"one_time_prekey_id,omitempty"`
OneTimePrekeyBody *string `json:"one_time_prekey_body,omitempty"`
}
102 changes: 102 additions & 0 deletions server/repository/e2ee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package repository

import (
"context"
"errors"

"github.com/commandlinecoding/elephant/server/config"
"github.com/commandlinecoding/elephant/server/models"
"github.com/jackc/pgx/v5"
)

type E2EERepository struct{}

func NewE2EERepository() *E2EERepository {
return &E2EERepository{}
}

func (r *E2EERepository) SaveDeviceKeys(ctx context.Context, uid string, req models.UploadKeysReq) error {
tx, err := config.DB.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)

deviceQuery := `
INSERT INTO user_devices (user_id, device_id, identity_key, signed_prekey, signed_prekey_signature, updated_at)
VALUES ($1::uuid, $2, $3, $4, $5, NOW())
ON CONFLICT (user_id, device_id)
DO UPDATE SET identity_key = $3, signed_prekey = $4, signed_prekey_signature = $5, updated_at = NOW();
`
_, err = tx.Exec(ctx, deviceQuery, uid, req.DeviceID, req.IdentityKey, req.SignedPrekey, req.Signature)
if err != nil {
return err
}

if len(req.OneTimePrekeys) > 0 {
otpQuery := `INSERT INTO one_time_prekeys (user_id, device_id, key_id, key_content) VALUES ($1::uuid, $2, $3, $4);`
for _, k := range req.OneTimePrekeys {
_, err = tx.Exec(ctx, otpQuery, uid, req.DeviceID, k.ID, k.Content)
if err != nil {
return err
}
}
}

return tx.Commit(ctx)
}

func (r *E2EERepository) FetchBundle(ctx context.Context, targetUID, deviceID string) (*models.PrekeyBundle, error) {
tx, err := config.DB.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)

baseQuery := `
SELECT identity_key, signed_prekey, signed_prekey_signature
FROM user_devices
WHERE user_id = $1::uuid AND device_id = $2;
`
var b models.PrekeyBundle
b.UserID = targetUID
b.DeviceID = deviceID

err = tx.QueryRow(ctx, baseQuery, targetUID, deviceID).Scan(&b.IdentityKey, &b.SignedPrekey, &b.Signature)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errors.New("device keys not found for target identity profile")
}
return nil, err
}

otpQuery := `
DELETE FROM one_time_prekeys
WHERE id = (
SELECT id FROM one_time_prekeys
WHERE user_id = $1::uuid AND device_id = $2
LIMIT 1
)
RETURNING key_id, key_content;
`
var otpID int
var otpBody string
err = tx.QueryRow(ctx, otpQuery, targetUID, deviceID).Scan(&otpID, &otpBody)
if err == nil {
b.OneTimePrekeyID = &otpID
b.OneTimePrekeyBody = &otpBody
}

if err := tx.Commit(ctx); err != nil {
return nil, err
}

return &b, nil
}

func (r *E2EERepository) GetOTPCount(ctx context.Context, uid, deviceID string) (int, error) {
query := `SELECT COUNT(*) FROM one_time_prekeys WHERE user_id = $1::uuid AND device_id = $2;`
var count int
err := config.DB.QueryRow(ctx, query, uid, deviceID).Scan(&count)
return count, err
}
1 change: 1 addition & 0 deletions server/routes/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ func ApiRoute(router chi.Router) {
router.Route("/users", UserRoute)
router.Route("/messages", MessageRoute)
router.Route("/groups", GroupRoute)
router.Route("/e2ee", E2EERoute)
}
14 changes: 14 additions & 0 deletions server/routes/e2ee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package routes

import (
"github.com/commandlinecoding/elephant/server/controllers"
"github.com/commandlinecoding/elephant/server/middlewares"
"github.com/go-chi/chi/v5"
)

func E2EERoute(router chi.Router) {
router.Use(middlewares.AuthGuard)

router.Post("/keys", controllers.HandleUploadE2EEKeys)
router.Get("/bundle/{userId}", controllers.HandleGetPrekeyBundle)
}
7 changes: 3 additions & 4 deletions server/routes/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ func UserRoute(router chi.Router) {

router.Use(middlewares.AuthGuard)

router.Get("/me", controllers.HandleGetMe) // Resolves to /api/users/me
router.Get("/{id}", controllers.HandleGetUserByID) // Resolves to /api/users/:id

router.With(searchLimiter.Limit).Get("/search", controllers.HandleUserSearch) // Resolves to /api/users/search
router.Get("/me", controllers.HandleGetMe)
router.With(searchLimiter.Limit).Get("/search", controllers.HandleUserSearch)
router.Get("/{id}", controllers.HandleGetUserByID)
}
39 changes: 39 additions & 0 deletions server/services/e2ee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package services

import (
"context"
"errors"

"github.com/commandlinecoding/elephant/server/models"
"github.com/commandlinecoding/elephant/server/repository"
)

type E2EEService struct {
repo *repository.E2EERepository
}

func NewE2EEService() *E2EEService {
return &E2EEService{repo: repository.NewE2EERepository()}
}

func (s *E2EEService) UploadKeys(ctx context.Context, uid string, req models.UploadKeysReq) error {
if req.DeviceID == "" || req.IdentityKey == "" || req.SignedPrekey == "" || req.Signature == "" {
return errors.New("missing core payload parameters for prekey bundle submission")
}
return s.repo.SaveDeviceKeys(ctx, uid, req)
}

func (s *E2EEService) GetBundle(ctx context.Context, targetUID, deviceID string) (*models.PrekeyBundle, error) {
if deviceID == "" {
deviceID = "main"
}
return s.repo.FetchBundle(ctx, targetUID, deviceID)
}

func (s *E2EEService) CheckKeysExhaustion(ctx context.Context, uid string, deviceID string) (int, bool, error) {
count, err := s.repo.GetOTPCount(ctx, uid, deviceID)
if err != nil {
return 0, false, err
}
return count, count < 10, nil
}
Loading