diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f36cde2..6296725 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -10,6 +10,9 @@ are not as thoroughly tested. The web server hosting Grima must support https, and Grima must be accessed over https for authentication to work properly. +Grima must be run with Chrome, Firefox, or Safari; authentication will +not work properly with Edge or Internet Explorer. + Grima requires libxml to parse Alma's replies, libcurl to make its API queries, and PDO to make some very minimal database queries (for users and apikeys). The default database provider is an sqlite3 file in `/tmp/grima`. Grima has been diff --git a/grimas/Login/Login.js b/grimas/Login/Login.js index 2e0a062..ea105e4 100644 --- a/grimas/Login/Login.js +++ b/grimas/Login/Login.js @@ -1,5 +1,10 @@ (function(){ +// Edge lacks: +// * TextEncoder for UTF-8 bytes to use strings in crypto +// * crypto.subtle.digest("SHA-1") to query well known passwords +// * crypto.subtle.deriveBits("PBKDF2") to hide human password from server + if (document.readyState == "complete") { setup(); } else { @@ -9,7 +14,17 @@ if (document.readyState == "complete") { function setup() { for ( const elt of document.querySelectorAll( "input[name='password']" ) ) { elt.form.addEventListener("submit", onSubmit); + elt.addEventListener("input", onInput ); + } +} + +function onInput( event ) { + const elt = event.target; + if ( elt.timer ) { + clearTimeout( elt.timer ); } + markPasswordGood( elt ); + elt.timer = setTimeout( () => checkForWellKnownPassword(elt).catch(()=>{}), 300 ); } function onSubmit( event ) { @@ -17,7 +32,7 @@ function onSubmit( event ) { if (form instanceof HTMLFormElement) { const passwordElt = form.elements.namedItem("password"); const usernameElt = form.elements.namedItem("username"); - const institutionElt = form.elements.namedItem("institutionElt"); + const institutionElt = form.elements.namedItem("institution"); if ( (passwordElt && passwordElt.value) && (!passwordElt.value.startsWith( "PBKDF2-" )) ) { @@ -25,19 +40,79 @@ function onSubmit( event ) { const username = usernameElt ? usernameElt.value : "user"; const institution = institutionElt ? institutionElt.value : "institution"; const password = passwordElt.value; - hash( password, `grima-clientside-login-v1:${institution}:${username}` ) - .then( (hash) => { + const checkPromise = checkForWellKnownPassword( passwordElt ) + const hashPromise = hash( password, `grima-clientside-login-v1:${institution}:${username}` ) + Promise.all( [ checkPromise, hashPromise ] ) + .then( ([_,hash]) => { passwordElt.value = hash; form.submit(); } ) - .catch( err => { - passwordElt.setCustomValidity( err.toString() ); - } ); + .catch( err => markPasswordBad( elt, err.toString(), "Cannot send this password" ) ) return false; } } } +function markPasswordBad( elt, validityMessage, buttonText ) { + elt.setCustomValidity( validityMessage.toString() ); + for (const btnelt of elt.form.querySelectorAll('input[type="submit"]') ) { + btnelt.classList.add( "btn-danger" ); + btnelt.value = buttonText; + } +} + +function markPasswordGood( elt ) { + elt.setCustomValidity( "" ); + for (const btnelt of elt.form.querySelectorAll('input[type="submit"]') ) { + btnelt.classList.remove( "btn-danger" ); + btnelt.value = "Submit"; + } +} + +const checked = new Map(); +function checkForWellKnownPassword( elt ) { + if ( (elt instanceof HTMLInputElement) + && (window.crypto) + && (window.crypto.subtle) + ) { + if (checked.has(elt.value)) { + const wellKnown = checked.get(elt.value); + if (wellKnown) { + const err = `That password has been used by ${wellKnown} compromised accounts.`; + const btn = "Don't send such a well-known password to server"; + markPasswordBad( elt, err, btn ); + return Promise.reject( err ); + } else { + markPasswordGood( elt ); + return Promise.resolve(); + } + } + return window.crypto.subtle + .digest("SHA-1", bin(elt.value) ) + .then( sha1 => hex(sha1) ) + .then( sha1 => fetch( `https://api.pwnedpasswords.com/range/${sha1.substring(0,5)}`) + .then( response => response.text() ) + .then( text => { + for (const line of text.split(/\r\n/g)) { + const [ rest, wellKnown ] = line.split(/:/g); + if (sha1.substring(5) === rest.toLowerCase()) { + const err = `That password has been used by ${wellKnown} compromised accounts.`; + const btn = "Don't send such a well-known password to server"; + markPasswordBad( elt, err, btn ); + checked.set( elt.value, wellKnown ); + return Promise.reject(err); + } + } + markPasswordGood( elt ); + checked.set( elt.value, 0 ); + return Promise.resolve(); + }) + ); + } else { + return Promise.resolve(); + } +} + function hash( password, salt_seed ) { const name = "PBKDF2"; const hash = "SHA-512"; @@ -50,12 +125,19 @@ function hash( password, salt_seed ) { } } return window.crypto.subtle - .digest( "SHA-512", bin( salt_seed ) ) + .digest( hash, bin( salt_seed ) ) .then( salt => window.crypto.subtle .importKey( "raw", bin(password), {name}, false, ["deriveBits"]) .then( (pw) => window.crypto.subtle .deriveBits( {name, salt, iterations, hash}, pw, 128 ) ) .then( (key) => `PBKDF2-${hash}\$${iterations}\$${hex(salt)}\$${hex(key)}` ) ) + .catch( (err) => { + if (err.name === "PBKDF2") { + return Promise.reject( "Client side crypto not available. Please don't use Edge."); + } else { + throw err; + } + }); } function bin(str) {