================================================================================================================================================================

Password-protected resources on static-site webhosters

hashing html5 webcrypto webdev websec

Scenario

Some web hosters only serve static files and allow no config changes to the webserver. But maybe you want to provide files which are not intended for public view, for example sharing a file with a friend. Therefore, the best you can do is protecting files by giving them names which are hard to guess. Obviously these files should also not be linked somewhere publicly at all.

This concept can be expanded with a clientside-only authentication mechanism, as described next.

Login process

1. The user opens the webpage

A login dialog with password input is shown to the user. The user inputs a password.

>> click here for a demo <<

2. Clientside password hashing

Now the password must be locally digested on the webpage. A hashing algorithm suitable for passwords must be applied. PBKDF2 as provided by the WebCryptoAPI is acceptable with an iteration count of 310,000 in HMAC-SHA-256 mode. The hash should be salted with at least 16 bytes of randomness. The salt can be stored as plaintext alongside the login page. Generating a salt is as easy as dd if=/dev/urandom bs=1 count=16 | base64.

/** clientside hashing a password
 * @param {string} password - as provided by user
 * @param {string} salt - as base64 encoded
 * @return {Promise<string>} - the hash value
 */
async function hashPassword(password, salt) {
    const passwordKey = await window.crypto.subtle.importKey(
        "raw",
        new TextEncoder().encode(password),
        {name: "PBKDF2"},
        false, // key should not be extractable
        ["deriveBits"]
    )
    const hashBuffer = await window.crypto.subtle.deriveBits(
        {"name": "PBKDF2", salt: base64ToArrayBuffer(salt), "iterations": 310_000, "hash": "SHA-256"},
        passwordKey,
        256
    )
    const hashArray = Array.from(new Uint8Array(hashBuffer))
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase()
}

/** converts a base64 encoded string into an arraybuffer
 * @param {string} base64text
 * @return {ArrayBuffer}
 */
function base64ToArrayBuffer(base64text) {
    const bytes = new Uint8Array(base64text.length)
    for (let i = 0; i < base64text.length; i++)
        bytes[i] = base64text.charCodeAt(i)
    return bytes.buffer
}

3. Redirect to the secret path

The created hash-value is taken as a path parameter for the url. As UX improvement, a preflight fetch request checks if the entered password is correct. If that’s the case, a redirect is performed. The user is now authenticated.

const password = document.querySelector('input[type=password]').value
const salt = 'ChangeTheSaltValueASAP=='
const hashValue = await hashPassword(password, salt)
const url = window.location.origin + window.location.pathname + '/' + hashValue
fetch(url).then(async res => {
    if (res.ok)
        window.location.replace(url)
    else throw Error(await res.text())
}).catch(err => {
    alert('Password wrong')  // todo: evaluate error msg
})

It’s possible to create user specific protected paths by concatenating the static salt with a provided additional userID. That way separate accounts with userID and password as credentials would be possible.

Pros

Cons

Conclusion

Is it possible? Yes, absolutely! And should we implement this? Please don’t, if it’s avoidable in any way. The explained approach is only useful in a very specific scenario (see above). In almost all cases there would be a more standard-applying way to realize that, for example using good old HTTP Basic Auth. Or initiate a session after login instead, so there is no further exchange of highly privileged key material (user credentials) required.