diff --git a/mockid/route.go b/mockid/route.go index 09b5cb1..5c7536e 100644 --- a/mockid/route.go +++ b/mockid/route.go @@ -11,6 +11,7 @@ import ( "fmt" "io/ioutil" "log" + "net" "net/http" "os" "path/filepath" @@ -28,8 +29,30 @@ import ( ua "github.com/mileusna/useragent" ) +var errTokenNotVerified = apiError{"token has not been verified"} +var errUsedToken = apiError{"token has already been used"} +var errInvalidEmail = apiError{"invalid email address"} + +// API Errors +type serverError struct { + error string +} + +func (e serverError) Error() string { + return e.error +} + +type apiError struct { + error string +} + +func (e apiError) Error() string { + return e.error +} + type HTTPResponse struct { - Error string `json:"error"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` Success bool `json:"success"` } @@ -135,13 +158,22 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler { return } + contact, err = lintEmail(contact) + if nil != err { + b, _ := json.Marshal(&HTTPResponse{ + Error: err.Error(), + Code: "E_USER", + }) + w.Write(b) + return + } + _, ok, err := contactKV.Load(contact) if nil != err { fmt.Fprintf(os.Stderr, "meta: error loading contact: %s\n", err.Error()) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - if ok { b, _ := json.Marshal(&HTTPResponse{ Success: true, @@ -152,6 +184,7 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler { b, _ := json.Marshal(&HTTPResponse{ Error: "not found", + Code: "E_USER", }) w.Write(b) }) @@ -217,6 +250,13 @@ func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler { otp, err = consumeOTPReceipt("TODO", receipt, agent, addr) } if nil != err { + if errTokenNotVerified == err { + b, _ := json.Marshal(&HTTPResponse{ + Error: err.Error(), + }) + w.Write(b) + return + } // TODO propagate error types http.Error(w, "Bad Request", http.StatusBadRequest) fmt.Fprintf(w, "%s", err) @@ -821,7 +861,7 @@ func newOTP(email string, agent string, addr string) (id string, secret []byte, // keep it secret, keep it safe os.FileMode(0600), ); nil != err { - return "", nil, errors.New("database connection failed when writing verification token") + return "", nil, serverError{"database connection failed when writing verification token"} } return receipt, secret, nil } @@ -847,12 +887,12 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error tokfile := filepath.Join(tokenPrefix, hash+".tok.txt") b, err := ioutil.ReadFile(tokfile) if nil != err { - return nil, errors.New("database connection failed when reading verification token") + return nil, serverError{"database connection failed when reading verification token"} } otp := OTP{} if err := json.Unmarshal(b, &otp); nil != err { - return nil, errors.New("database verification token parse failed") + return nil, serverError{"database verification token parse failed"} } if 0 == subtle.ConstantTimeCompare([]byte(otp.Email), []byte(email)) { @@ -862,7 +902,7 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error if consume.secret { if !otp.SecretUsed.IsZero() { - return nil, errors.New("token has already been used") + return nil, errUsedToken } otp.SecretUsed = time.Now() if addr != otp.ReceiptIP { @@ -874,10 +914,10 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error } } else if consume.receipt { if otp.SecretUsed.IsZero() { - return nil, errors.New("token has not been verified") + return nil, errTokenNotVerified } if !otp.ReceiptUsed.IsZero() { - return nil, errors.New("token has already been used") + return nil, errUsedToken } otp.ReceiptUsed = time.Now() } @@ -890,24 +930,38 @@ func checkOTP(hash, email, agent, addr string, consume otpConsumer) (*OTP, error // keep it secret, keep it safe os.FileMode(0600), ); nil != err { - return nil, errors.New("database connection failed when consuming token") + return nil, serverError{"database connection failed when consuming token"} } } - fmt.Println("THE TOKEN IS GOOD. GOOD!!") - return &otp, nil } +func lintEmail(email string) (string, error) { + // TODO check DNS for MX records + parts := strings.Split(email, "@") + domain := parts[1] + if 2 != len(parts) || strings.Contains(email, " \t\n") { + return "", errInvalidEmail + } + mxs, err := net.LookupMX(domain) + if len(mxs) < 1 || nil != err { + // TODO it possible in some cases that this + // could be a network error + return "", errInvalidEmail + } + return strings.ToLower(email), nil +} + func startVerification(baseURL, contact, agent, addr string) (receipt string, err error) { email := strings.Replace(strings.TrimPrefix(contact, "mailto:"), " ", "+", -1) if "" == email { return "", errors.New("missing contact:[\"mailto:me@example.com\"]") } - // TODO check DNS for MX records - if !strings.Contains(email, "@") || strings.Contains(email, " \t\n") { - return "", errors.New("invalid email address") + email, err = lintEmail(email) + if nil != err { + return "", err } // TODO expect JWK in JWS/JWT @@ -922,9 +976,10 @@ func startVerification(baseURL, contact, agent, addr string) (receipt string, er subject := "Verify New Account" // TODO go tpl // TODO determine OS and Browser from user agent + page := "pocket/iframe.html" text := fmt.Sprintf( - "It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s#/verify/%s\n\n%s on %s %s from %s\n\nNot you? Just ignore this message.", - baseURL, base64.RawURLEncoding.EncodeToString(secret), ua.Name, ua.OS, ua.Device, addr, + "It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s/%s#/verify/%s\n\n%s on %s %s from %s\n\nNot you? Just ignore this message.", + baseURL, page, base64.RawURLEncoding.EncodeToString(secret), ua.Name, ua.OS, ua.Device, addr, ) fmt.Println("email:", text) if !strings.Contains(contact, "+noreply") { diff --git a/public/hashcash.js b/public/hashcash.js deleted file mode 100644 index 751cd48..0000000 --- a/public/hashcash.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; -/*global crypto*/ - -var Hashcash = module.exports; - -var textEncoder = new TextEncoder(); -Hashcash.solve = async function solveHc(hc) { - var solution = 0; - var parts = hc.split(':').slice(0, 6); - if (parts.length < 6) { - throw new Error('invalid Hashcash-Challenge: ' + hc); - } - - var bits = parseInt(parts[1], 10) || -1; - if (bits > 10 || bits < 0) { - throw new Error('bad bit values'); - } - console.log('bits:', bits); - hc = parts.join(':') + ':'; - async function next() { - var answer = hc + int52ToBase64(solution); - var u8 = textEncoder.encode(answer); - // REALLY SLOW due to async tasks and C++ context switch - var hash = await crypto.subtle.digest('SHA-256', u8); - hash = new Uint8Array(hash); - if (checkHc(hash, bits)) { - return answer; - } - solution += 1; - return next(); - } - - return next(); -}; - -function int52ToBase64(n) { - var hex = n.toString(16); - if (hex.length % 2) { - hex = '0' + hex; - } - - var bin = []; - var i = 0; - var d; - var b; - while (i < hex.length) { - d = parseInt(hex.slice(i, i + 2), 16); - b = String.fromCharCode(d); - bin.push(b); - i += 2; - } - - return btoa(bin.join('')).replace(/=/g, ''); -} - -function checkHc(hash, bits) { - var n = Math.floor(bits / 8); - var m = bits % 8; - var i; - if (m > 0) { - n += 1; - } - - for (i = 0; i < n && i < hash.length; i += 1) { - if (bits > 8) { - bits -= 8; - if (0 !== hash[i]) { - return false; - } - continue; - } - - if (0 !== hash[i] >> (8 - bits)) { - return false; - } - - return true; - } -} diff --git a/public/main.js b/public/main.js index c2afdde..b072671 100644 --- a/public/main.js +++ b/public/main.js @@ -1,214 +1,3 @@ 'use strict'; -var request = require('./request.js'); -var PocketId = require('./pocketid.js'); -var state = {}; -var auths = clearAuths(); - -function $$(sel, el) { - if (el) { - return el.querySelectorAll(sel) || []; - } - return document.body.querySelectorAll(sel) || []; -} - -function $(sel, el) { - if (el) { - return el.querySelector(sel); - } - return document.body.querySelector(sel); -} - -function clearAuths() { - var _auths = { - google: { - promise: null, - idToken: '' - } - }; - _auths.google.promise = new Promise(function (res, rej) { - _auths.google.resolve = res; - _auths.google.reject = rej; - }); - return _auths; -} - -window.onSignIn = async function onSignIn(googleUser) { - // Useful data for your client-side scripts: - var profile = googleUser.getBasicProfile(); - // Don't send this directly to your server! - console.log('ID: ' + profile.getId()); - console.log('Full Name: ' + profile.getName()); - console.log('Given Name: ' + profile.getGivenName()); - console.log('Family Name: ' + profile.getFamilyName()); - console.log('Image URL: ' + profile.getImageUrl()); - console.log('Email: ' + profile.getEmail()); - - // The ID token you need to pass to your backend: - auths.google.idToken = googleUser.getAuthResponse().id_token; - console.log('ID Token: ' + auths.google.idToken); - auths.google.resolve(auths.google.idToken); -}; - -function setFlow(cont, flow) { - $$(cont).forEach(function (el) { - el.hidden = true; - }); - console.log(flow); - $(flow).hidden = false; -} - -async function unlock() { - var key; - try { - key = await PocketId.unlock(function () { - setFlow('.authn-container', '.authn-unlock'); - return new Promise(function (resolve, reject) { - window.unlocker = { resolve: resolve, reject: reject }; - }); - }); - } catch (e) { - console.error( - "Had a key, but couldn't unlock it. TODO: Just send email?" - ); - console.error(e); - return; - } - - setFlow('.authn-container', '.authn-loading'); - - if (key) { - genTokenWithKey(key); - return; - await PocketId.createIdToken({ key: key }); - } - - PocketId.signIdToken(id_token).then(function (resp) { - console.log('Response:'); - console.log(resp); - }); -} - -function genTokenWithKey() { - // TODO: generate token - // TODO: check if the key is still considered valid - // TODO: generate new key and authorize -} - -(async function () { - var loc = window.location; - - console.log('/new-hashcash?'); - var resp = await request({ - method: 'POST', - url: loc.protocol + '//' + loc.hostname + '/api/new-hashcash' - }); - console.log(resp); - - console.log('/test-hashcash?'); - resp = await request({ - method: 'POST', - url: loc.protocol + '//' + loc.hostname + '/api/test-hashcash' - }); - console.log(resp); -})(); - -setFlow('.authn-container', '.authn-email'); - -$('.authn-email form').addEventListener('submit', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - state.email = $('.authn-email [name=username]').value; - - setFlow('.authn-container', '.authn-loading'); - return PocketId.auth - .meta({ email: state.email }) - .catch(function (err) { - window.alert('Error: ' + err.message); - }) - .then(function (resp) { - // if the user exists, go to the continue screen - // otherwise go to the new user screen - console.log('meta:', resp); - if (!resp.body.success) { - // This is a completely new user - setFlow('.authn-container', '.authn-new-user'); - return; - } - // The user exists, but this is a new device - setFlow('.authn-container', '.authn-existing'); - }); -}); - -function verifyNewDevice() { - return PocketId.auth - .verify({ scheme: 'mailto:', email: state.email }) - .catch(function (err) { - window.alert('Error: ' + err.message); - }) - .then(function (resp) { - console.log(resp); - localStorage.setItem( - 'pocketid', // + state.email, - JSON.stringify({ - receipt: resp.body.receipt, - email: state.email, - createdAt: new Date().toISOString() - }) - ); - window.alert("Go check yo' email!"); - return PocketId.auth - .consume({ - email: state.email, - receipt: resp.body.receipt - }) - .then(function (resp) { - // this should have a token we can inspect - // and return to the calling application. - console.log(resp); - window.alert('all set!'); - }); - }); -} - -$('.authn-existing form').addEventListener('submit', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - setFlow('.authn-container', '.authn-loading'); - verifyNewDevice(); -}); - -$('.authn-new-user form').addEventListener('submit', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - - // We don't need to worry about checking if the key exists - // even if it does, the account has been deactivated - - setFlow('.authn-container', '.authn-loading'); - verifyNewDevice(); -}); - -var route = window.location.hash.split('/').slice(1); -console.log('route:', route); -switch (route[0]) { - case 'verify': - var pstate = JSON.parse(localStorage.getItem('pocketid') || '{}'); - PocketId.auth - .consume({ - receipt: pstate.receipt, - secret: route[1] - }) - .then(function (resp) { - console.log('token for this device to save:', resp); - window.alert('goodness!'); - }) - .catch(function (e) { - console.error(e); - window.alert('network error, try again'); - }); - break; - default: - // do nothing -} +require('./pocket/consumer.js'); diff --git a/public/pocketid.js b/public/pocketid.js deleted file mode 100644 index 7b54dde..0000000 --- a/public/pocketid.js +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -var Keypairs = require('@root/keypairs'); -var PocketId = module.exports; -var request = require('./request.js'); - -var keyJson = window.localStorage.getItem('private.jwk.json'); - -PocketId.signIdToken = async function (idToken) { - var pair = await Keypairs.parseOrGenerate({ key: keyJson }); - var jwt = await Keypairs.signJwt({ - jwk: pair.private, - iss: window.location.protocol + '//' + window.location.hostname, - exp: '15m', - claims: { - contact: ['google:' + idToken] - } - }); - return jwt; -}; - -PocketId.auth = {}; -PocketId.auth.meta = async function ({ email }) { - var loc = window.location; - var body = await request({ - method: 'GET', - url: - loc.protocol + - '//' + - loc.hostname + - '/api/authn/meta?contact=' + - 'mailto:' + - email - }); - return body; -}; - -PocketId.auth.verify = async function ({ scheme, email }) { - if (!scheme) { - scheme = 'mailto:'; - } - - var loc = window.location; - var body = await request({ - method: 'GET', - url: - loc.protocol + - '//' + - loc.hostname + - '/api/authn/verify?contact=' + - scheme + - email - }); - - return body; -}; - -PocketId.auth.consume = async function ({ - email = '', - receipt = '', - secret = '', - count = 0 -}) { - var loc = window.location; - var resp = await request({ - method: 'GET', - url: - loc.protocol + - '//' + - loc.hostname + - '/api/authn/consume?contact=' + - (email ? 'mailto:' + email : '') + - '&receipt=' + - receipt + - '&secret=' + - secret - }); - - if (resp.body.success) { - // There should be a token here - // (or the pubkey should have been given beforehand) - return resp.body; - } - - if (resp.body.error) { - // TODO special errors are hard failures - } - - if (count > 600) { - throw new Error('abandoned login'); - } - - return timeout(5000).then(function () { - console.log('check otp again'); - return PocketId.auth.consume({ - email, - secret, - receipt, - count: count || 0 - }); - }); -}; - -async function timeout(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, ms); - }); -} - -var textEncoder = new TextEncoder(); -PocketId.genKey = async function ({ email }) { - // Ideally we'd use PBKDF2 or better but... web standards... - // TODO put a random salt - var emailU8 = textEncoder.encode(email); - var salt = await crypto.subtle.digest('SHA-256', emailU8); - var u8 = textEncoder.encode(answer); - var hash = await crypto.subtle.digest('SHA-256', u8); -}; diff --git a/public/request.js b/public/request.js deleted file mode 100644 index c9a23d5..0000000 --- a/public/request.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -var Hashcash = require('./hashcash.js'); -var _sites = {}; - -module.exports = async function (opts) { - if (!opts.headers) { - opts.headers = {}; - } - - if (opts.json) { - if (true === opts.json) { - opts.body = JSON.stringify(opts.body); - } else { - opts.body = JSON.stringify(opts.json); - } - if (!opts.headers['Content-Type'] && !opts.headers['content-type']) { - opts.headers['Content-Type'] = 'application/json'; - } - } - - if (!opts.mode) { - opts.mode = 'cors'; - } - - var url = new URL(opts.url); - if (!_sites[url.hostname]) { - _sites[url.hostname] = { nonces: [] }; - } - - var site = _sites[url.hostname]; - var hc = site.hashcashChallenge; - if (hc) { - delete site.hashcashChallenge; - site.hashcash = await Hashcash.solve(hc); - } - if (site.hashcash) { - opts.headers.Hashcash = site.hashcash; - } - - var response = await window.fetch(opts.url, opts); - var headerNames = response.headers.keys(); - var hs = {}; - var h; - while (true) { - h = headerNames.next(); - if (h.done) { - break; - } - hs[h.value] = response.headers.get(h.value); - } - - var body; - if (hs['content-type'].includes('application/json')) { - body = await response.json(); - } else { - body = await response.text(); - try { - body = JSON.parse(body); - } catch (e) { - // ignore - } - } - var resp = {}; - - resp.body = body; - resp.headers = hs; - resp.toJSON = function () { - return { - headers: hs, - body: body - }; - }; - - if (resp.headers['hashcash-challenge']) { - _sites[url.hostname].hashcashChallenge = - resp.headers['hashcash-challenge']; - } - if (resp.headers.nonce) { - site.nonces.push(resp.headers.nonce); - } - - console.log(resp); - return resp; -};