diff --git a/BUG_AUDIT.md b/BUG_AUDIT.md index 9b1285e..c72d4c5 100644 --- a/BUG_AUDIT.md +++ b/BUG_AUDIT.md @@ -107,7 +107,7 @@ 1. ~~**Build warnings cleanup**~~ ✅ — implicit `cstring` conversion (wal.nim), `HoleEnumConv` (server.nim), unused `os` import (logging.nim), `ImplicitDefaultValue` (ast.nim) 2. **Threadpool deprecation** — миграция към `malebolgia`/`weave`/`taskpools` -3. **Auth SCRAM-SHA-256** — истински challenge-response със salt + iteration count +3. ~~**Auth SCRAM-SHA-256**~~ ✅ — истински challenge-response със salt + iteration count 4. ~~**TLA+ symmetry reduction**~~ ✅ — `SYMMETRY` добавен във всички 9 `.cfg` файла + `Permutations` дефиниции в `.tla` спековете 5. ~~**`crossmodal.tla`**~~ ✅ — cross-modal consistency между document/vector/graph/FTS 6. **Replication data transfer** — `writeLsn` да изпраща данни към replicas diff --git a/PLAN.md b/PLAN.md index 2b69a56..0ebdea6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -104,7 +104,7 @@ - Моделира sync между document/vector/graph/FTS индекси - Резултат: 10-ти TLA+ спек, пълно покритие на core модулите -### Опция C: Auth hardening + SCRAM (3-4 часа) +### Опция C: Auth hardening + SCRAM ✅ - Истински SCRAM-SHA-256 със salt, iteration count, challenge-response - Резултат: production-grade auth diff --git a/src/barabadb/core/httpserver.nim b/src/barabadb/core/httpserver.nim index fcba950..f6e8711 100644 --- a/src/barabadb/core/httpserver.nim +++ b/src/barabadb/core/httpserver.nim @@ -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()) diff --git a/src/barabadb/protocol/auth.nim b/src/barabadb/protocol/auth.nim index ad8c20c..b73f545 100644 --- a/src/barabadb/protocol/auth.nim +++ b/src/barabadb/protocol/auth.nim @@ -1,9 +1,10 @@ -## Authentication — JWT-based auth with SCRAM-SHA-256 +## Authentication — JWT-based auth with real SCRAM-SHA-256 import std/strutils import std/base64 import std/tables import std/times import checksums/sha2 +import scram type AuthMethod* = enum @@ -38,10 +39,22 @@ type AuthManager* = ref object secretKey*: string tokens*: seq[string] - users*: Table[string, string] # username -> password hash + users*: Table[string, string] # username -> password hash (legacy) + scramUsers*: Table[string, ScramCredential] + scramSessions*: Table[string, ScramServerState] proc newAuthManager*(secretKey: string = ""): AuthManager = - AuthManager(secretKey: secretKey, tokens: @[], users: initTable[string, string]()) + AuthManager( + secretKey: secretKey, + tokens: @[], + users: initTable[string, string](), + scramUsers: initTable[string, ScramCredential](), + scramSessions: initTable[string, ScramServerState](), + ) + +# --------------------------------------------------------------------------- +# Base64 URL-safe helpers +# --------------------------------------------------------------------------- proc base64UrlEncode(data: string): string = result = encode(data) @@ -53,6 +66,10 @@ proc base64UrlDecode(data: string): string = s &= "=" return decode(s) +# --------------------------------------------------------------------------- +# Legacy HMAC-SHA-256 (for JWT signing) +# --------------------------------------------------------------------------- + proc hmacSha256(key, message: string): string = var k = key if k.len > 64: @@ -81,6 +98,18 @@ proc hmacSha256(key, message: string): string = return $outerHash +proc constantTimeCompare(a, b: string): bool = + if a.len != b.len: + return false + var diff = 0 + for i in 0.. 0: creds.payload else: hmacSha256(am.secretKey, "") if stored == clientHash or stored == hmacSha256(am.secretKey, creds.payload): return AuthResult(authenticated: true, username: creds.username, role: "user", database: "default") return AuthResult(authenticated: false, error: "Invalid SCRAM credentials") +# --------------------------------------------------------------------------- +# Token / user management +# --------------------------------------------------------------------------- + proc addToken*(am: var AuthManager, token: string) = am.tokens.add(token) @@ -200,4 +293,5 @@ proc revokeToken*(am: var AuthManager, token: string) = proc isAuthenticated*(r: AuthResult): bool = r.authenticated proc addScramUser*(am: var AuthManager, username, passwordHash: string) = + ## Legacy helper: stores raw hash. Use registerScramUser() for real SCRAM. am.users[username] = passwordHash diff --git a/src/barabadb/protocol/scram b/src/barabadb/protocol/scram new file mode 100755 index 0000000..cec9518 Binary files /dev/null and b/src/barabadb/protocol/scram differ diff --git a/src/barabadb/protocol/scram.nim b/src/barabadb/protocol/scram.nim new file mode 100644 index 0000000..d69c7e6 --- /dev/null +++ b/src/barabadb/protocol/scram.nim @@ -0,0 +1,251 @@ +## SCRAM-SHA-256 implementation per RFC 7677 +## Provides: PBKDF2, HMAC-SHA-256, nonce generation, SCRAM message parsing + +import std/strutils +import std/base64 +import std/endians +import checksums/sha2 + +const + DefaultIterationCount* = 4096 + NonceBytes = 24 + +type + ScramCredential* = object + salt*: string + iterationCount*: int + storedKey*: array[32, byte] + serverKey*: array[32, byte] + + ScramServerState* = object + username*: string + clientFirstMessageBare*: string + serverFirstMessage*: string + authMessage*: string + clientNonce*: string + serverNonce*: string + salt*: string + iterationCount*: int + storedKey*: array[32, byte] + serverKey*: array[32, byte] + +# --------------------------------------------------------------------------- +# Cryptographic primitives +# --------------------------------------------------------------------------- + +proc hmacSha256*(key, message: string): array[32, byte] = + ## HMAC-SHA-256 returning raw 32-byte digest. + var k = key + if k.len > 64: + var ctx = initSha_256() + ctx.update(k.toOpenArray(0, k.len-1)) + let hash = ctx.digest() + k = $hash + while k.len < 64: + k &= "\x00" + + var ipad = newString(64) + var opad = newString(64) + for i in 0..<64: + ipad[i] = chr(ord(k[i]) xor 0x36) + opad[i] = chr(ord(k[i]) xor 0x5c) + + var innerCtx = initSha_256() + innerCtx.update(ipad.toOpenArray(0, ipad.len-1)) + innerCtx.update(message.toOpenArray(0, message.len-1)) + let innerHash = innerCtx.digest() + + var outerCtx = initSha_256() + outerCtx.update(opad.toOpenArray(0, opad.len-1)) + outerCtx.update(innerHash.toOpenArray(0, innerHash.len-1)) + return cast[array[32, byte]](outerCtx.digest()) + +proc hmacSha256*(key: openArray[byte], message: string): array[32, byte] = + let keyStr = newString(key.len) + if key.len > 0: + copyMem(addr keyStr[0], unsafeAddr key[0], key.len) + return hmacSha256(keyStr, message) + +proc hmacSha256*(key, message: openArray[byte]): array[32, byte] = + let keyStr = newString(key.len) + if key.len > 0: + copyMem(addr keyStr[0], unsafeAddr key[0], key.len) + let msgStr = newString(message.len) + if message.len > 0: + copyMem(addr msgStr[0], unsafeAddr message[0], message.len) + return hmacSha256(keyStr, msgStr) + +proc hmacSha256*(key: string, message: openArray[byte]): array[32, byte] = + let msgStr = newString(message.len) + if message.len > 0: + copyMem(addr msgStr[0], unsafeAddr message[0], message.len) + return hmacSha256(key, msgStr) + +proc sha256*(data: string): array[32, byte] = + var ctx = initSha_256() + ctx.update(data.toOpenArray(0, data.len-1)) + return cast[array[32, byte]](ctx.digest()) + +proc sha256*(data: openArray[byte]): array[32, byte] = + var s = newString(data.len) + if data.len > 0: + copyMem(addr s[0], unsafeAddr data[0], data.len) + return sha256(s) + +proc xorBytes*(a, b: openArray[byte]): seq[byte] = + result = newSeq[byte](a.len) + for i in 0.. 0: salt else: generateSalt() + let saltedPassword = pbkdf2HmacSha256(password, actualSalt, iterationCount) + let clientKey = hmacSha256(saltedPassword, "Client Key") + let storedKey = sha256(clientKey) + let serverKey = hmacSha256(saltedPassword, "Server Key") + ScramCredential( + salt: actualSalt, + iterationCount: iterationCount, + storedKey: storedKey, + serverKey: serverKey, + ) + +# --------------------------------------------------------------------------- +# SCRAM message parsing / building +# --------------------------------------------------------------------------- + +proc parseClientFirst*(msg: string): (string, string, string) = + ## Parse client-first-message: gs2-header,username,nonce + ## Returns: (gs2_header, username, nonce) + var parts = msg.split(",") + if parts.len < 3: + raise newException(ValueError, "Invalid client-first-message") + var gs2 = parts[0] + var username = "" + var nonce = "" + for i in 1.. 0: + copyMem(addr proofBytes[0], addr proof[0], proof.len) + return (cbind, nonce, proofBytes) + +proc buildServerFirst*(nonce, salt: string, iterationCount: int): string = + "r=" & nonce & ",s=" & encode(salt) & ",i=" & $iterationCount + +proc buildServerFinal*(serverSignature: openArray[byte]): string = + var sigStr = newString(serverSignature.len) + if serverSignature.len > 0: + copyMem(addr sigStr[0], unsafeAddr serverSignature[0], serverSignature.len) + "v=" & encode(sigStr) + +# --------------------------------------------------------------------------- +# SCRAM server-side verification +# --------------------------------------------------------------------------- + +proc verifyClientProof*(state: ScramServerState, clientProof: openArray[byte]): bool = + let clientKey = xorBytes(clientProof, @(hmacSha256(state.storedKey, state.authMessage))) + let computedStoredKey = sha256(clientKey) + for i in 0..<32: + if computedStoredKey[i] != state.storedKey[i]: + return false + return true + +proc computeServerSignature*(state: ScramServerState): array[32, byte] = + return hmacSha256(state.serverKey, state.authMessage) diff --git a/tests/test_all.nim b/tests/test_all.nim index d1aef2f..2942370 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -7,6 +7,7 @@ import std/asyncdispatch import std/times import std/random import std/monotimes +import std/base64 import barabadb/core/types import barabadb/core/mvcc @@ -50,6 +51,7 @@ import barabadb/fts/engine as fts import barabadb/protocol/wire import barabadb/protocol/pool import barabadb/protocol/auth +import barabadb/protocol/scram import barabadb/protocol/ratelimit import barabadb/schema/schema as schema @@ -707,6 +709,87 @@ suite "Authentication": authMethod: amToken, payload: "invalid_token")) check not result.authenticated + test "SCRAM-SHA-256 full handshake": + var am = newAuthManager() + am.registerScramUser("alice", "wonderland") + + # Step 1: ClientFirstMessage + let clientNonce = generateNonce() + let clientFirst = "n,,n=alice,r=" & clientNonce + + # Step 2: ServerFirstMessage + let serverFirst = am.startScram(clientFirst) + check serverFirst.startsWith("r=") + check serverFirst.contains("s=") + check serverFirst.contains("i=4096") + + # Parse serverFirst + var combinedNonce = "" + var saltB64 = "" + var iterCount = 0 + for part in serverFirst.split(","): + if part.startsWith("r="): combinedNonce = part[2..^1] + elif part.startsWith("s="): saltB64 = part[2..^1] + elif part.startsWith("i="): iterCount = parseInt(part[2..^1]) + check combinedNonce.len > 0 + check saltB64.len > 0 + check iterCount == 4096 + + # Step 3: Compute client proof + let salt = decode(saltB64) + let saltedPassword = pbkdf2HmacSha256("wonderland", salt, iterCount) + let clientKey = hmacSha256(saltedPassword, "Client Key") + let storedKey = sha256(clientKey) + let serverKey = hmacSha256(saltedPassword, "Server Key") + + let clientFirstBare = "n=alice,r=" & clientNonce + let clientFinalWithoutProof = "c=biws,r=" & combinedNonce + let authMessage = clientFirstBare & "," & serverFirst & "," & clientFinalWithoutProof + + let clientSignature = hmacSha256(storedKey, authMessage) + var clientProof = newSeq[byte](32) + for i in 0..<32: + clientProof[i] = clientKey[i] xor clientSignature[i] + var proofStr = newString(32) + copyMem(addr proofStr[0], addr clientProof[0], 32) + let proofB64 = encode(proofStr) + # Strip padding + var proofB64Clean = proofB64 + while proofB64Clean.endsWith("="): + proofB64Clean.setLen(proofB64Clean.len - 1) + proofB64Clean = proofB64Clean.replace("+", "-").replace("/", "_") + + let clientFinal = "c=biws,r=" & combinedNonce & ",p=" & proofB64Clean + + # Step 4: Server verifies + let (ok, serverFinal) = am.finishScram(clientFinal) + check ok + check serverFinal.startsWith("v=") + + # Verify server signature ourselves + let expectedServerSig = hmacSha256(serverKey, authMessage) + var expectedStr = newString(32) + copyMem(addr expectedStr[0], unsafeAddr expectedServerSig[0], 32) + let expectedB64 = encode(expectedStr) + check serverFinal == "v=" & expectedB64 + + test "SCRAM-SHA-256 invalid proof rejected": + var am = newAuthManager() + am.registerScramUser("bob", "builder") + + let clientNonce = generateNonce() + let clientFirst = "n,,n=bob,r=" & clientNonce + let serverFirst = am.startScram(clientFirst) + + var combinedNonce = "" + for part in serverFirst.split(","): + if part.startsWith("r="): combinedNonce = part[2..^1] + + let clientFinal = "c=biws,r=" & combinedNonce & ",p=invalidproof" + let (ok, serverFinal) = am.finishScram(clientFinal) + check not ok + check serverFinal == "e=invalid-proof" + suite "Vector Quantization": test "Scalar quantization 8-bit": var sq = newScalarQuantizer(4, bits = 8)