A customer maintenance form in Uniface 10, part 13 - salted password hashes, rights checks in the services, and a session timeout
Part 9 added users, three roles and a login lockout - as services, without a login dialog. The password hash from part 9 was one HMAC-SHA256 per password, keyed with the application name and the user name. That is better
Part 9 added users, three roles and a login lockout - as services, without a login dialog. The password hash from part 9 was one HMAC-SHA256 per password, keyed with the application name and the user name. That is better than plain text and worse than anything you would choose today: no random salt, one round, fast to brute-force.
This part closes three gaps:
- a v2 password hash with a random salt and a configurable number of rounds, plus an upgrade path for existing v1 hashes,
- rights checks inside the services that change data, not only in the forms,
- a session timeout that logs a user out after a period of inactivity.
And it finally adds the forms that make all of this usable: a login dialog and a change-password dialog.
The v2 hash
The stored value carries everything needed to check it later:
v2:<salt as hex>:<rounds>:<hash as hex>
The version prefix is the important part. It lets old and new hashes live in the same column, and it leaves room for a v3.
Generating a hash:
entry HASH_V2
params
string pPassword : IN
string pHash : OUT
endparams
variables
string vSalt, vHex, vRoundsText
numeric vRounds
endvariables
pHash = ""
activate "SETTINGS_SVC".GET_SETTING("PASSWORD_ROUNDS", "10000", vRoundsText)
vRounds = vRoundsText
if (vRounds < 1)
vRounds = 10000
endif
vSalt = $encode("HEX", $random("raw", 16))
if (vSalt = "")
return -1
endif
call DERIVE(pPassword, vSalt, vRounds, vHex)
pHash = "v2:%%(vSalt):%%(vRounds):%%(vHex)"
return 0
end
$random("raw", 16) returns 16 random bytes; $encode("HEX", ...) makes them storable. The derivation itself is a plain loop:
entry DERIVE
params
string pPassword : IN
string pSalt : IN
numeric pRounds : IN
string pHex : OUT
endparams
variables
numeric vRound
endvariables
pHex = pSalt
vRound = 0
while (vRound < pRounds)
pHex = $encode("HEX", $encode("HMAC_SHA256", pHex, pPassword))
vRound = vRound + 1
endwhile
return 0
end
Each round feeds the previous result back into HMAC-SHA256 with the password as the key. The salt is the starting value, so the same password gives a different hash for every user.
To be clear about what this is: an iterated HMAC, not PBKDF2, bcrypt or Argon2. $encode offers HMAC-SHA256 but none of the standard key-derivation functions, so this is the closest thing that can be built from what is there. It makes brute force slower by the round count and defeats precomputed tables because of the salt. It is not a reviewed standard construction, and if the platform ever offers PBKDF2, the version prefix makes a switch possible.
Two more weak spots I would point out in a review myself:
- The comparison is a plain string comparison (
vCalc = vHash), not a constant-time one. For a desktop application that checks passwords locally, timing attacks are not a realistic threat; for a network service it would matter. - A loop of HMAC calls in an interpreted language is slow per round. That means fewer rounds for the same waiting time than a native implementation would get, which is why the round count has to be measured (see below).
Checking a password, and upgrading on the fly
Checking reads the stored value, splits it, and re-runs the derivation:
if (pStored[1:3] = "v2:")
vList = $replace(pStored, 1, ":", $string("&uSEP;"), -1)
getitem vVersion, vList, 1
getitem vSalt, vList, 2
getitem vRounds, vList, 3
getitem vHash, vList, 4
if (vSalt = "" | vHash = "" | vRounds < 1)
return 0
endif
call DERIVE(pPassword, vSalt, vRounds, vCalc)
if (vCalc = vHash)
pOk = 1
endif
return 0
endif
vKey = $concat("CUSTOMER_MANAGEMENT:", $uppercase(pUser))
vCalc = $encode("HEX", $encode("HMAC_SHA256", pPassword, vKey))
if (vCalc = pStored)
pOk = 1
pOld = 1
endif
Replacing : with the Uniface item separator turns the stored string into a list that getitem can read. Anything without the v2: prefix is treated as a v1 hash and checked the old way. If that succeeds, pOld is set.
The login uses that flag: after a successful v1 check it computes a v2 hash from the password it has just been given and stores it. Users never notice the migration, and after everyone has logged in once there are no v1 hashes left. This is the only moment the plain-text password is available, so it is the only moment the upgrade can happen.
The login operation also returns more than ok/not ok:
| Status | Meaning |
|---|---|
| 0 | logged in |
| 1 | no users existed; this user was created as administrator |
| 2 | logged in, but the password was reset and must be changed now |
| -1 | refused (text in pError) |
Status 2 comes from an administrator's password reset. The login dialog reacts to it by opening the change-password dialog straight away and logging the user out again if they cancel.
How many rounds?
"10000 rounds" is a guess. Whether that takes 20 milliseconds or two seconds depends on the machine. So the service measures it:
public operation CALIBRATE
params
numeric pTargetMs : IN
numeric pRounds : OUT
numeric pMs : OUT
string pError : OUT
endparams
...
call NOW_MS(vStart)
call DERIVE("calibration", "00", 2000, vHex)
call NOW_MS(vEnd)
pMs = vEnd - vStart
if (pMs < 1)
pMs = 1
endif
sql "SELECT MAX(1000, MIN(500000, CAST(%%(vTarget) * 2.0 / %%(pMs) AS INTEGER) * 1000))", "CUSTOMERS"
...
pRounds = $result
vRounds = "%%(pRounds)"
activate "SETTINGS_SVC".SET_SETTING("PASSWORD_ROUNDS", vRounds, pError)
return $status
end
It runs 2000 rounds, measures the time, and scales up to the target time (for example 300 ms), rounded down to whole thousands and kept between 1000 and 500000. Existing hashes keep their own round count, because it is stored in the hash. Only new hashes use the new setting.
The millisecond clock comes from SQLite, because a connection is open anyway:
entry NOW_MS
params
numeric pMs : OUT
endparams
pMs = 0
sql "SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)", "CUSTOMERS"
if ($status >= 0)
pMs = $result
endif
return 0
end
julianday('now') - 2440587.5 is days since 1970; times 86400000 gives milliseconds. The settings form has a "Calibrate" button that calls this operation.
Rights checks belong in the services
Part 9 defined the roles READ < EDIT < ADMIN and a HAS_RIGHT check. What was missing was a place that enforces it. Hiding buttons in the menu is not enforcement: every service can be called from any other component.
So there is a small service whose only job is to refuse with a readable sentence:
public operation REQUIRE
params
string pRight : IN
string pError : OUT
endparams
variables
string vUser, vRole
boolean vAllowed
endvariables
pError = ""
activate "USER_SVC".HAS_RIGHT(pRight, vAllowed)
if (vAllowed)
return 0
endif
activate "USER_SVC".CURRENT_USER(vUser, vRole)
pError = "This function needs the role %%(pRight). You are logged in as %%(vUser) with the role %%(vRole)."
return -1
end
and one line at the top of every service operation that changes data in bulk or for good - merge, anonymize, import, the bulk clean-ups:
activate "RIGHTS_SVC".REQUIRE("EDIT", pError)
if ($status < 0)
return -1
endif
The clean-up service has a dry-run mode that only counts. The check sits inside if (!pDryRun), so a reader may still see what would change.
One design decision needs stating honestly. Without a login, CURRENT_USER returns the operating system user with the role EDIT. That keeps all older tests and the developer's own Compile & Test runs working without logging in first. It also means that anyone who can start the components directly, without the main menu, works with EDIT rights. The main menu now always asks for a login first, but the default is a convenience for development, not a security boundary.
The tests for this part log in a READ user and check that merge, anonymize, import and the real clean-up are all refused with a text that names the role, that the clean-up dry run is still allowed, and that everything works again after logout.
A session timeout without a timer
The logged-in user lives in Uniface's numbered general variables ($91 user, $92 role), which keep their value as long as the application runs. A third one, $93, holds the time of the last activity in milliseconds:
public operation CHECK
params
string pError : OUT
endparams
variables
string vText
numeric vNow, vLast, vLimit, vIdle
endvariables
pError = ""
call NOW_MS(vNow)
if ($91 = "")
$93 = vNow
return 0
endif
activate "SETTINGS_SVC".GET_SETTING("SESSION_TIMEOUT", "30", vText)
vLimit = vText
vLast = $93
if (vLimit > 0 & vLast > 0)
vIdle = (vNow - vLast) / 60000
if (vIdle >= vLimit)
activate "USER_SVC".LOGOUT()
$93 = ""
pError = "Your session has expired after %%(vLimit) minutes without activity. Please log in again."
return -1
endif
endif
$93 = vNow
return 0
end
There is no background timer. The check runs whenever the user picks a function in the main menu or the "More functions" menu:
entry SESSION_OK
variables
string vError
numeric vOk
endvariables
activate "SESSION_SVC".CHECK(vError)
if ($status < 0)
message/info vError
activate "LOGIN_FRM".exec(vOk)
if (vOk != 1)
return -1
endif
endif
return 0
end
If the session has expired, the user sees why and gets the login dialog. Logging in again continues with the function they picked.
The limitation is obvious from the code: someone who leaves a form open is only caught at their next menu choice, not while the form is on screen. A timeout of 0 switches the check off.
Testing time-based behaviour without waiting 30 minutes needs a back door, and the service has one: SET_LAST_ACTIVITY(pMinutesAgo) moves the last activity into the past. The tests use it to check "3 idle minutes are fine", "after the limit the user is logged out" and "timeout 0 never expires".
The two dialogs
The login dialog is small: user name, password, two buttons and an info line. Two details are worth copying:
- The password field is cleared right after its value has been read, before the service call. Whatever happens next, the password is not left on screen.
- When the user table is empty, the info line says "No users yet. The first login creates the administrator." Without that hint, the first start of a fresh installation looks like a login with no valid account.
The result is passed back through a component variable, because edit returns before the form closes:
operation exec
params
numeric pResult : OUT
endparams
...
$cResult$ = 0
edit
pResult = $cResult$
end
Component variables are declared in the form's Declarations section and must be written with dollar signs in the code ($cResult$). Writing cResult instead compiles, but with a warning "Field 'CRESULT' not found", and the value goes nowhere.
The change-password dialog asks for the old password and the new one twice, and shows the password rule (at least 8 characters with a letter and a digit) before the user types anything.
Results
- New services:
RIGHTS_SVC,SESSION_SVC. Additions toAUTH_SVC(CALIBRATE). Rights checks in four existing services. - The test service for this stage has 34 tests; together with the older suites, 8 test services and 371 tests run in one go, all passing, and the database is unchanged afterwards.
- A click test against the real database: the first login created the administrator, the main menu, the new sub-menu and the settings, user, password and other dialogs opened, and the test user was removed again afterwards.
Takeaways
Version your hashes. A prefix like v2: costs three characters and makes every future change a migration instead of a reset.
Upgrade at login. It is the only time you have the plain password, so it is the only time you can re-hash it.
Measure the cost, don't guess it. A calibration that writes the round count into a setting is twenty lines.
Say what your construction is. An iterated HMAC is not PBKDF2. Writing that down keeps the next reader from trusting it more than it deserves.
Enforce rights where the data changes. Menus decide what users see; services decide what happens.
A timeout does not need a timer. Checking at every menu action is simple, testable, and good enough for a desktop application - as long as you know where it stops.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.