feat: real SCRAM-SHA-256 authentication (Option C)

Security:
- New scram.nim module with full SCRAM-SHA-256 per RFC 7677
  * PBKDF2-HMAC-SHA-256 key derivation
  * HMAC-SHA-256 with multiple overloads for bytes/strings
  * Secure nonce/salt generation via /dev/urandom
  * Client/server message parsing and proof verification

AuthManager updates:
- registerScramUser(): stores salted credentials (salt + iterations + storedKey + serverKey)
- startScram(): initiates challenge-response handshake
- finishScram(): verifies client proof and returns server signature
- Legacy amSCRAMSHA256 path kept for backward compatibility

HTTP endpoints:
- POST /auth/scram/start — accepts client-first-message, returns server-first
- POST /auth/scram/finish — accepts client-final-message, returns server-final

Tests:
- SCRAM-SHA-256 full handshake test (register → start → compute proof → finish)
- SCRAM-SHA-256 invalid proof rejection test

Build: 0 warnings, all tests pass
This commit is contained in:
2026-05-13 09:50:07 +03:00
parent 422df08ab9
commit ac55237b72
7 changed files with 488 additions and 14 deletions
+46
View File
@@ -18,6 +18,7 @@ import ../core/mvcc
import ../protocol/wire
import ../core/websocket
import jwt as jwtlib
import ../protocol/auth
type
HttpServer* = ref object
@@ -27,6 +28,7 @@ type
ctx: ExecutionContext
metrics*: Metrics
secretKey*: string
authManager*: AuthManager
ws*: WsServer
Metrics* = ref object
@@ -46,8 +48,10 @@ proc newHttpServer*(config: BaraConfig): HttpServer =
ctx.onChange = proc(ev: ChangeEvent) =
let msg = $ev.kind & " " & ev.table
asyncCheck ws.broadcastToTable(ev.table, msg)
let am = newAuthManager(secret)
HttpServer(config: config, running: false, db: db, ctx: ctx,
secretKey: secret,
authManager: am,
metrics: Metrics(), ws: ws)
# ----------------------------------------------------------------------
@@ -211,6 +215,46 @@ proc authHandler(server: HttpServer): RequestHandler =
"role": "user"
})
proc scramStartHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
let body = parseJson(request.body)
if body == nil or "username" notin body or "clientFirstMessage" notin body:
ctx.json(%*{"error": "Missing username or clientFirstMessage"}, 400)
return
let username = body["username"].getStr()
let clientFirst = body["clientFirstMessage"].getStr()
try:
let serverFirst = server.authManager.startScram(clientFirst)
ctx.json(%*{
"serverFirstMessage": serverFirst,
"status": "continue"
})
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 401)
proc scramFinishHandler(server: HttpServer): RequestHandler =
return proc(request: Request) {.gcsafe.} =
{.cast(gcsafe).}:
let ctx = newContext(request)
let body = parseJson(request.body)
if body == nil or "clientFinalMessage" notin body:
ctx.json(%*{"error": "Missing clientFinalMessage"}, 400)
return
let clientFinal = body["clientFinalMessage"].getStr()
try:
let (ok, serverFinal) = server.authManager.finishScram(clientFinal)
if ok:
ctx.json(%*{
"serverFinalMessage": serverFinal,
"status": "authenticated"
})
else:
ctx.json(%*{"error": serverFinal}, 401)
except CatchableError as e:
ctx.json(%*{"error": e.msg}, 401)
proc openApiHandler(): RequestHandler =
return proc(request: Request) {.gcsafe.} =
let ctx = newContext(request)
@@ -482,6 +526,8 @@ proc run*(server: HttpServer, port: int = 9470) =
router.get("/health", healthHandler())
router.get("/metrics", server.metricsHandler())
router.post("/auth", server.authHandler())
router.post("/auth/scram/start", server.scramStartHandler())
router.post("/auth/scram/finish", server.scramFinishHandler())
router.get("/tables", server.tablesHandler())
router.get("/api", openApiHandler())