/*
* Copyright (c) 2018 Zippie Ltd.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as appcache from './appcache'
import * as ipc from './ipc/'
import * as secp256k1 from './secp256k1'
/**
* The DeviceInfo type contains unique information associated with this device.
* Information like the devices unique ID is hashed with the application origin
* to stop developers from being able to track devices across different domains
* and applications.
*
* @typedef {Object} Vault#DeviceInfo
*
* @property {string} deviceId Unique device ID
*
*/
/**
* @typedef {Object} Vault#SigninOpts
*
* @property {string} [launch] URI to redirect to after the signin process is completed.
*
*/
/**
* @typedef {Object} Vault#VaultOpts
*
* @property {string} vault_uri Specify custom vault location
*
*/
/**
* @typedef {Object} Vault#VersionInfo
*
* @property {string} BUILD_VERSION Vault build version
* @property {string} BUILD_TIMESTAMP Vault build timestamp
*/
/**
* Class for initializing and integrating the Zippie Platform into an
* application. This class should be instantiated and setup in the main
* entry point of your code to properly handle signing a user in.
*
* After calling [setup()]{@link Vault#setup}, you may query the instance
* property [isSignedIn]{@link Vault#isSignedIn} to check to see if the user
* was automatically signed in. If not, then the [signin()]{@link Vault#signin}
* method must be called to trigger the signin process.
*
* It is good practice to build a button linked to the
* [signin()]{@link Vault#signin} process to work properly with browsers that
* have ITP 2.0 (Internet Tracking Prevention) support, like Safari and
* Firefox 65+
*
* @class Vault
*
* @param {Vault#VaultOpts} [opts] Vault API configuration.
*
* @returns {Vault} New vault instance
*
* @example
* const vault = new Vault({vault_uri: 'https://vault.dev.zippie.org'})
* vault.setup()
* .then(_ => vault.signin())
* .then(_ => {
* console.info('Vault initialized')
* })
* .catch(e => { console.error(e) })
*
*/
/**
* Vault version information
* @member {Vault#VersionInfo} Vault#version
*/
/**
* Vault services configuration
* @member {Object} Vault#config
*/
/**
* Indicates whether the vault is initialized and the user is successfully
* signed in with this application to their Zippie identity.
* @member {boolean} Vault#isSignedIn
*/
export default class Vault {
constructor (opts) {
opts = opts || {}
// Parse params from URI fragment
this.__params = this.__parse_opts(window.location)
// Strip params from URI fragment
if (window.location.hash.indexOf('?') !== -1) {
window.location.hash = window.location.hash.slice(0, window.location.hash.indexOf('?'))
}
// Enclave DOM objects
this.__iframe = null
this.__vault = null
// Message receiver dispatch variables
this.__callback_counter = 0
this.__receivers = {}
// Construct iframe to load vault enclave
var iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.sandbox += ' allow-storage-access-by-user-activation'
iframe.sandbox += ' allow-same-origin'
iframe.sandbox += ' allow-scripts'
// Setup vault URI default if none provided in constructor opts.
if (!opts.vault_uri) {
opts.vault_uri = 'https://vault.zippie.org'
if (window.location.host.split('.').indexOf('dev') !== -1) {
opts.vault_uri = 'https://vault.dev.zippie.org'
} else
if (window.location.host.split('.').indexOf('testing') !== -1) {
opts.vault_uri = 'https://vault.testing.zippie.org'
}
}
// If vault URI set in local storage, it overrides above default.
if (window.localStorage.getItem('zippie-vault-url') !== null) {
opts.vault_uri = window.localStorage.getItem('zippie-vault-url')
}
// 'zippie-vault' query parameter overrides and persists to local storage.
if (this.__params['zippie-vault'] !== undefined) {
opts.vault_uri = this.__params['zippie-vault']
window.localStorage.setItem('zippie-vault-url', opts.vault_uri)
}
// Add vault enclave iframe to DOM
document.body.appendChild(iframe)
this.__opts = opts
this.__iframe = iframe
this.__vault = iframe.contentWindow
}
/**
* Initialize the vault enclave by loading the vault into an iframe with
* a magiccookie key (if available). Resolves when the vault is ready for
* receiving messages.
*
* This function may result in an automated signin, you can test for this
* by reading the [isSignedIn]{@link Vault#isSignedIn} property.
*
* @method Vault#setup
*/
async setup () {
// Don't allow setup to be called multiple times.
if (this.__onSetupReady !== undefined) return Promise.resolve()
await ipc.init(this)
await secp256k1.init(this)
console.info('VAULT-API: Setting up Zippie Vault enclave.')
return new Promise(function (resolve, reject) {
if ('ipc-mode' in this.__opts) {
console.info('VAULT-API: Running in IPC mode.')
// Setup async response handlers for when we hear "ready" from vault.
this.__onSetupReady = resolve
this.__onSetupError = reject
// Setup incoming message handler.
this.__on_message = this.__on_message.bind(this)
this.__vault = window.parent
window.addEventListener('message', this.__on_message)
return resolve()
}
// Get magic vault cookie by whatever means necessary, if provided via
// query parameters, then save /new/ value to local storage.
let magiccookie = window.localStorage.getItem('zippie-vault-cookie')
if (this.__params['vault-cookie'] !== undefined) {
magiccookie = this.__params['vault-cookie']
window.localStorage.setItem('zippie-vault-cookie', magiccookie)
}
if (magiccookie === '') magiccookie = null
// If no magic cookie was discovered redirect to vault in root mode,
// to pick up a new magic cookie, or require user sign up.
if (!this.__params['inhibit-signup'] && !magiccookie) {
console.warn('VAULT-API: No vault cookie provided, redirecting to vault.')
window.location = this.__opts.vault_uri +
'#?launch=' + window.location + ';inhibit-signup'
return reject()
}
// Setup incoming message handler.
this.__on_message = this.__on_message.bind(this)
window.addEventListener('message', this.__on_message)
// We have a magic cookie, which means we should have an identity
// initialize vault with our cookie.
if (magiccookie !== null) {
console.info('VAULT-API: Found magic cookie:', magiccookie)
this.__iframe.src = this.__opts.vault_uri + '#?magiccookie=' + magiccookie
} else {
this.__iframe.src = this.__opts.vault_uri
}
// Setup async response handlers for when we hear "ready" from vault.
this.__onSetupReady = resolve
this.__onSetupError = reject
console.info('VAULT-API: Loading vault from URI:', this.__iframe.src)
}.bind(this))
}
/**
* When vault is setup correctly, this function initiates a signin process,
* which should be called from an interactive user component, like a button
* to work correctly with browsers that have ITP 2.0 (Internet Tracking
* Prevention) support, like Safari and Firefox 65+
*
* @method Vault#signin
*
* @param {Vault#SigninOpts} [opts] Options to pass to Vault during signin process.
* @param {bool} [noLogin] (internal use only)
*/
signin (opts, noLogin) {
if (this.isSignedIn) return Promise.resolve()
this.__signin_opts = opts || {}
console.info('VAULT-API: Attempting to signin.')
// If we've been launched with inhibit-signup specified, and vault reports
// no identity (no magiccookie set). Initiate signup process.
let magiccookie = window.localStorage.getItem('zippie-vault-cookie')
if (this.__params['inhibit-signup'] && !magiccookie) {
this.__signin_opts.launch = this.__signin_opts.launch || window.location.href
let paramstr = ''
Object.keys(this.__signin_opts).forEach(k => {
paramstr += (paramstr.length === 0 ? '' : ';')
+ k + '=' + this.__signin_opts[k]
})
console.info('VAULT-API: Redirecting to:', this.__opts.vault_uri + '#?' + paramstr)
window.location = this.__opts.vault_uri + '#?' + paramstr
return Promise.reject()
}
return new Promise(function (resolve, reject) {
if (noLogin) return resolve()
// Send 'login' message for ITP support.
return this.message({login: null})
.then(function (r) {
console.info('VAULT-API: Vault reports ITP access granted.')
this.__onSetupReady = resolve
this.__onSetupError = reject
// XXX - https://bugs.webkit.org/show_bug.cgi?id=188783
// This should cause a "ready" message down the line, which is
// picked up by the above promise resolve/reject, which in turn
// triggers the continuation of the signin process.
this.message({reboot: null})
if ('itp' in this.__params) {
console.info('VAULT-API: ITP ITP ITP ITP')
delete this.__params['itp']
}
}.bind(this))
.catch(function (e) {
if (e !== 'ITP_REQUEST_FAILURE') return Promise.reject(e)
console.warn('VAULT-API: Vault reported ITP request failure, redirecting to vault for authorization.')
window.location = this.__opts.vault_uri + '#?launch=' + window.location + ';itp'
}.bind(this))
}.bind(this))
.then(function (r) {
if (this.isSignedIn === undefined) return Promise.reject('Not setup')
return this.message({signin: this.__signin_opts})
}.bind(this))
.then(function (r) {
if (r && 'error' in r && 'launch' in r && !this.__signin_opts['inhibit-signup']) {
this.__signin_opts.launch = this.__signin_opts.launch || window.location.href
let paramstr = ''
Object.keys(this.__signin_opts).forEach(k => {
paramstr += (paramstr.length === 0 ? '' : ';')
+ k + '=' + this.__signin_opts[k]
})
window.location = r.launch + '#?' + paramstr
}
return appcache.init(this)
}.bind(this))
.catch(function (e) {
if (e !== 'ITP_REQUEST_FAILURE') return Promise.reject(e)
console.warn('VAULT-API: Vault reported ITP request failure, redirecting to vault for authorization.')
window.location = this.__opts.vault_uri + '#?launch=' + window.location + ';itp'
}.bind(this))
}
/**
* Low-Level API which sends a raw request message to the vault enclave.
* Returns a promise that resolves or rejects depending on the received
* result.
*
* @method Vault#message
*
* @param {Vault#Message} request Message to send
*
* @returns {Promise} response
*/
message (req) {
if (!this.__iframe) {
return Promise.reject({ error: 'Vault not initialized.' })
}
return new Promise(function (resolve, reject) {
let id = 'callback-' + this.__callback_counter++
this.__receivers[id] = [resolve, reject]
req.callback = id
this.__vault.postMessage(req, '*')
}.bind(this))
}
/**
* Request this devices' identification information.
*
* @method Vault#getDeviceInfo
*
* @returns {Vault#DeviceInfo} Device information
*/
getDeviceInfo () {
return this.message({ getDeviceInfo: null })
}
/**
* Request identity enrollments, this is a list of devices and recovery
* methods.
*
* @method Vault#enrollments
*
* @private
*
* @returns {Array.<Vault#Enrollment>} Users' enrollments
*/
enrollments () {
return this.message({ enrollments: null })
}
/**
* Request user data
*
* @method Vault#getUserData
*
* @private
*
* @param {string} id User data key to get value of
*
* @returns {Any} Userdata
*/
getUserData (id) {
return this.message({ userdata: { get: { key: id } }})
}
/**
* Assign user data.
*
* @method Vault#setUserData
*
* @private
*
* @param {string} id User data key to overwrite value of
* @param {Any} value New value
*/
setUserData (id, value) {
return this.message({ userdata: { set: { key: id, value: value }}})
}
/**
* Event handler for incoming messages from vault.
*
* @access private
*/
__on_message (event) {
// Ignore messages not from vault
if (event.source !== this.__vault) return
// Ignore IPC messages which are handled in ipc module.
if ('call' in event.data) return
console.info('VAULT-API: Received message:', event.data)
// If there's a receiver setup for this message, handle it.
if (event.data.callback && this.__receivers[event.data.callback]) {
console.info('VAULT-API: Invoking message callback.')
let receiver = this.__receivers[event.data.callback]
delete this.__receivers[event.data.callback]
// Call receiver promise reject
if ('error' in event.data) return receiver[1](event.data.error)
// Call receiver promise resolve
return receiver[0](event.data.result)
}
if ('login' in event.data || 'ready' in event.data) {
console.info('VAULT-API: processing vault login/ready message.')
this.__get_vault_attr('version')()
.then(this.__get_vault_attr('config'))
.then(this.__get_vault_attr('isSignedIn'))
.then(async function () {
// If we have a magiccookie from a previous session, then retrieve
// it, and attempt an automatic signin, we can presume we've already
// been granted cookie access from a previous session.
let magiccookie = window.localStorage.getItem('zippie-vault-cookie')
if ('ready' in event.data && magiccookie) {
return this.signin(this.__signin_opts, true)
.then(this.__get_vault_attr('version'))
.then(this.__get_vault_attr('config'))
.then(this.__get_vault_attr('isSignedIn'))
.then(function () {
if (this.isSignedIn) return appcache.init(this)
}.bind(this))
.then(this.__onSetupReady)
.catch(e => { console.error(e) })
}
if (this.isSignedIn) await appcache.init(this)
return this.__onSetupReady()
}.bind(this))
return
}
console.warn('VAULT-API: unhandled vault message event:', event)
}
/**
* Parse hash query parameters into an object.
*
* @access private
*/
__parse_opts (uri) {
let parser = document.createElement('a')
parser.href = uri
let hash = parser.hash
let paramstr = hash.split('?')[1] || ''
hash = hash.split('?')[0]
let params = {}
let p = paramstr.split(';')
if (p[0] !== '') {
for (let i = 0; i < p.length; i++) {
let parts = p[i].split('=')
params[parts[0]] = decodeURIComponent(parts[1])
}
}
return params
}
/**
* Send vault message to request a vault, and store value in corrosponding
* instance attribute.
*
* @access private
*/
__get_vault_attr (attr) {
return function () {
let mesg = {}
mesg[attr] = null
return this.message(mesg).then(function (r) { this[attr] = r }.bind(this))
}.bind(this)
}
}