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
+106 -12
View File
@@ -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..<a.len:
diff = diff or (ord(a[i]) xor ord(b[i]))
return diff == 0
# ---------------------------------------------------------------------------
# JWT token helpers
# ---------------------------------------------------------------------------
proc createToken*(am: AuthManager, claims: JWTClaims): string =
let header = base64UrlEncode("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
var payloadJson = "{\"sub\":\"" & claims.sub & "\",\"role\":\"" & claims.role &
@@ -98,14 +127,6 @@ proc createToken*(am: AuthManager, claims: JWTClaims): string =
am.tokens.add(data & "." & base64UrlEncode(signature))
return am.tokens[^1]
proc constantTimeCompare(a, b: string): bool =
if a.len != b.len:
return false
var result = 0
for i in 0..<a.len:
result = result or (ord(a[i]) xor ord(b[i]))
return result == 0
proc verifyToken*(am: AuthManager, token: string): (bool, JWTClaims) =
let parts = token.split(".")
if parts.len != 3:
@@ -167,6 +188,73 @@ proc verifyToken*(am: AuthManager, token: string): (bool, JWTClaims) =
return (false, JWTClaims())
return (true, claims)
# ---------------------------------------------------------------------------
# SCRAM-SHA-256 challenge-response
# ---------------------------------------------------------------------------
proc registerScramUser*(am: AuthManager, username, password: string,
iterationCount: int = DefaultIterationCount) =
## Register a user with real SCRAM-SHA-256 credentials.
let cred = createScramCredential(password, iterationCount = iterationCount)
am.scramUsers[username] = cred
proc startScram*(am: AuthManager, clientFirstMessage: string): string =
## Start SCRAM authentication. Returns server-first-message.
let (_, username, clientNonce) = parseClientFirst(clientFirstMessage)
if username notin am.scramUsers:
raise newException(ValueError, "Unknown user: " & username)
let cred = am.scramUsers[username]
let serverNonce = generateNonce()
let combinedNonce = clientNonce & serverNonce
let serverFirst = buildServerFirst(combinedNonce, cred.salt, cred.iterationCount)
# Compute authMessage for later verification
let clientFirstMessageBare = "n=" & username & ",r=" & clientNonce
let authMessage = clientFirstMessageBare & "," & serverFirst
var state = ScramServerState(
username: username,
clientFirstMessageBare: clientFirstMessageBare,
serverFirstMessage: serverFirst,
authMessage: authMessage,
clientNonce: clientNonce,
serverNonce: serverNonce,
salt: cred.salt,
iterationCount: cred.iterationCount,
storedKey: cred.storedKey,
serverKey: cred.serverKey,
)
# Store session keyed by combined nonce
am.scramSessions[combinedNonce] = state
return serverFirst
proc finishScram*(am: AuthManager, clientFinalMessage: string): (bool, string) =
## Finish SCRAM authentication. Returns (success, server-final-message).
let (cbind, nonce, clientProof) = parseClientFinal(clientFinalMessage)
if nonce notin am.scramSessions:
return (false, "e=invalid-nonce")
var state = am.scramSessions[nonce]
am.scramSessions.del(nonce)
# Update authMessage with client-final-message-without-proof
let clientFinalWithoutProof = "c=" & cbind & ",r=" & nonce
state.authMessage = state.authMessage & "," & clientFinalWithoutProof
if not verifyClientProof(state, clientProof):
return (false, "e=invalid-proof")
let serverSignature = computeServerSignature(state)
return (true, buildServerFinal(serverSignature))
# ---------------------------------------------------------------------------
# Credential validation dispatcher
# ---------------------------------------------------------------------------
proc validateCredentials*(am: AuthManager, creds: AuthCredentials): AuthResult =
case creds.authMethod
of amNone:
@@ -180,15 +268,20 @@ proc validateCredentials*(am: AuthManager, creds: AuthCredentials): AuthResult =
role: claims.role, database: claims.database)
return AuthResult(authenticated: false, error: "Invalid token")
of amSCRAMSHA256:
## Legacy fallback: simple hash comparison for backward compatibility.
## Real SCRAM should use startScram() / finishScram().
if creds.username in am.users:
let stored = am.users[creds.username]
# SCRAM-SHA-256: client sends SHA-256(password) as payload
let clientHash = if creds.payload.len > 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