.
````
================================================
FILE: README.md
================================================
# Hereditas
[](https://open.vscode.dev/ItalyPaleAle/hereditas)
[](https://npmjs.org/package/hereditas)
[](https://npmjs.org/package/hereditas)
[](https://github.com/ItalyPaleAle/hereditas/blob/master/package.json)
## What happens to your digital life after you're gone?

Hereditas, which means *inheritance* in Latin, is a static website generator that builds **fully-trustless digital legacy boxes**, where you can store information for your relatives to access in case of your sudden death or disappearance.
For example, you could use this to pass information such as passwords, cryptographic keys, cryptocurrency wallets, sensitive documents, etc.
## Learn more
Read the [Hereditas announcement](https://withblue.ink/2019/03/18/what-happens-to-your-digital-life-after-youre-gone-introducing-hereditas.html?utm_source=web&utm_campaign=hereditas-github) to understand more on why we need Hereditas.
You can also watch this short [intro video](https://www.youtube.com/watch?v=lZEKgB5dzQ4).
## Get started and documentation
❓ [**What is Hereditas**](https://hereditas.app)
🚀 [**Get started guide**](https://hereditas.app/guides/get-started.html)
🔐 [**Security model**](https://hereditas.app/introduction/security-model.html)
📘 [**Documentation and CLI reference**](https://hereditas.app)
## Screenshot

## Warning: alpha quality software
**Hereditas is currently alpha quality software; use at your own risk.** While we've developed Hereditas with security always as the top priority, this software leverages a lot of cryptographic primitives under the hood. We won't release a stable (e.g. "1.0") version of Hereditas until we're confident that enough people and cryptography experts have audited and improved the code.
**Your help is highly appreciated.** If you are an expert on security or cryptography, please help us reviewing the code and let us know what you think - including if everything looks fine, or if you found a bug.
Responsible disclosure: if you believe you've found a security issue that could compromise current users of Hereditas, please [report it confidentially](https://www.npmjs.com/advisories/report?package=hereditas).
## License
Copyright © 2020, Alessandro Segala @ItalyPaleAle
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You can read the full text of the license in the [LICENSE.md](./LICENSE.md) file.
================================================
FILE: app/components/NavBar.svelte
================================================
================================================
FILE: app/components/PassphraseBox.svelte
================================================
{#await $box.fetchIndex()}
Fetching index, please wait…
{:then response}
{:catch error}
Error while fetching the index: {error}
{/await}
================================================
FILE: app/components/RequestAuthentication.svelte
================================================
{#if $authError}
Authentication error
Error description: {$authError}
Try authenticating again
{:else}
Authenticate with this Hereditas box
Authenticate
{/if}
================================================
FILE: app/components/UserProfile.svelte
================================================
Hello, {$profile.name}!
{#if $hereditasProfile.role == 'owner'}
You're the owner of this Hereditas box, so you can unlock it at any time.
By logging in, you have stopped the timer for the waiting period before other users can unlock your box.
{:else}
{#if $hereditasProfile.token}
You can now access to the content of this Hereditas.
{:else}
Thanks for requesting access. This Hereditas box will be unlocked on {unlockedDate.toLocaleString().replace(/ /g, '\xa0')} . Please check later.
Important: if an owner signs in with their account, this Hereditas will be locked again.
{/if}
{/if}
================================================
FILE: app/layout/App.svelte
================================================
================================================
FILE: app/lib/Base64Utils.js
================================================
// Based on: https://github.com/danguer/blog-examples/blob/master/js/base64-binary.js
/*
Copyright (c) 2011, Daniel Guerrero
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL DANIEL GUERRERO BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/*
* Uses the new array typed in javascript to binary base64 encode/decode
* at the moment just decodes a binary base64 encoded
* into either an ArrayBuffer (decodeArrayBuffer)
* or into an Uint8Array (decode)
*
* References:
* https://developer.mozilla.org/en/JavaScript_typed_arrays/ArrayBuffer
* https://developer.mozilla.org/en/JavaScript_typed_arrays/Uint8Array
*/
const keyStr = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
/* will return a Uint8Array type */
export function DecodeArrayBuffer(input) {
input = RemovePaddingChars(input)
const bytes = (input.length / 4) * 3
const ab = new ArrayBuffer(bytes)
Decode(input, ab)
return ab
}
function RemovePaddingChars(input) {
for (let i = 0; i < Math.min(2, input.length); i++) {
if (input.charAt(input.length - 1) == keyStr[64]) {
input = input.substring(0, input.length - 1)
}
}
return input
}
export function Decode(input, arrayBuffer) {
input = RemovePaddingChars(input)
const bytes = parseInt((input.length / 4) * 3, 10)
let uarray
let chr1, chr2, chr3
let enc1, enc2, enc3, enc4
let i = 0
let j = 0
if (arrayBuffer) {
uarray = new Uint8Array(arrayBuffer)
}
else {
uarray = new Uint8Array(bytes)
}
input = input.replace(/[^A-Za-z0-9+/=]/g, '')
for (i = 0; i < bytes; i += 3) {
//get the 3 octects in 4 ascii chars
enc1 = keyStr.indexOf(input.charAt(j++))
enc2 = keyStr.indexOf(input.charAt(j++))
enc3 = keyStr.indexOf(input.charAt(j++))
enc4 = keyStr.indexOf(input.charAt(j++))
chr1 = (enc1 << 2) | (enc2 >> 4)
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2)
chr3 = ((enc3 & 3) << 6) | enc4
uarray[i] = chr1
if (enc3 != 64) {
uarray[i + 1] = chr2
}
if (enc4 != 64) {
uarray[i + 2] = chr3
}
}
return uarray
}
================================================
FILE: app/lib/Box.js
================================================
import {Decrypt, buf2str, UnwrapKey, DeriveKeyArgon2, DeriveKeyPBKDF2} from './CryptoUtils'
import {DecodeArrayBuffer} from './Base64Utils'
/**
* Manages the Hereditas box
*/
export class Box {
constructor() {
this._masterKey = null
this._contents = null
this._indexFetchingPromise = null
this._encryptedIndex = null
}
/**
* Returns true if the box is unlocked
*
* @returns {boolean}
*/
isUnlocked() {
return this._masterKey && this._contents
}
/**
* Lock the box again, removing the key and the decrypted index from memory
*/
lock() {
this._masterKey = null
this._contents = null
}
/**
* Returns decrypted index
* @returns {Array}
*/
getContents() {
return this.isUnlocked() ?
this._contents :
[]
}
/**
* Fetches a content from the box, then decrypts it before returning.
*
* @param {Object} info - Info for the content to retrieve. Must contain the `dist` and `tag` properties.
* @returns {Promise} Promise that resolves with info object containing the decrypted content, as a binary ArrayBuffer in the `info.data` property, or an utf-8 encoded string in the `info.text` property (if `info.display` is "text" or "html").
* @async
*/
fetchContent(info) {
// If the box is locked, return
if (!this.isUnlocked()) {
return Promise.reject('Box is locked')
}
// Ensure we have what we need
if (!info || !info.dist || !info.tag) {
return Promise.reject('Content not found')
}
// Return the promise
let iv = null
let data = null
return fetch(info.dist)
// Grab the encrypted contents as ArrayBuffer
.then((response) => response.arrayBuffer())
// Decrypt the data
.then((buffer) => {
// The first 40 bytes are the wrapped key, and the next 12 bytes are the IV
const wrappedKey = buffer.slice(0, 40)
iv = buffer.slice(40, 52)
data = buffer.slice(52)
// Un-wrap the key
return UnwrapKey(this._masterKey, wrappedKey)
})
.then((key) => {
// Get the tag
const tag = DecodeArrayBuffer(info.tag)
return Decrypt(key, iv, data, tag)
.then((data) => {
// Clone the info object
info = JSON.parse(JSON.stringify(info))
// If it's text, decode it
if (info.display == 'text' || info.display == 'html') {
info.text = buf2str(new Uint8Array(data))
}
else {
info.data = data
}
return info
})
})
}
/**
* Fetches the index from the box.
*
* @returns {Promise} Promise that resolves (with no value) when the index has been fetched
* @async
*/
fetchIndex() {
// If we have the index already, do nothing
if (this._encryptedIndex) {
return Promise.resolve()
}
// If we're already fetching the index, return the promise
if (this._indexFetchingPromise) {
return this._indexFetchingPromise
}
// Fetch the index
this._indexFetchingPromise = fetch('_index')
// Grab the contents as ArrayBuffer
.then((response) => response.arrayBuffer())
// Store the results in the object
.then((buffer) => {
// Read the data from the response
this._encryptedIndex = {
// The first 40 bytes are the wrapped key, and the next 12 bytes are the IV
wrappedKey: buffer.slice(0, 40),
iv: buffer.slice(40, 52),
data: buffer.slice(52)
}
// Request is done
this._indexFetchingPromise = null
})
// Return the promise
return this._indexFetchingPromise
}
/**
* Attempts to decrypt the data using the passphrase and the app token
*
* @param {string} passphrase - Passphrase typed by the user
* @param {string} appToken - Encryption token for the app
* @async
* @throws Throws an exception if the decryption fails, which usually means that the key/passphrase is wrong
*/
unlock(passphrase, appToken) {
if (!passphrase || !appToken) {
return Promise.reject('Empty passphrase or app token')
}
// If we haven't fetched the index yet, return
if (!this._encryptedIndex) {
return Promise.resolve(false)
}
// Convert from Base64 to ArrayBuffer
const keySalt = DecodeArrayBuffer(process.env.KEY_SALT)
const indexTag = DecodeArrayBuffer(process.env.INDEX_TAG)
// Key derivation function: PBKDF2 or Argon2
let kdf
if (process.env.KEY_DERIVATION_FUNCTION == 'pbkdf2') {
kdf = DeriveKeyPBKDF2
}
else if (process.env.KEY_DERIVATION_FUNCTION == 'argon2') {
kdf = DeriveKeyArgon2
}
else {
throw Error('Invalid key derivation function requested')
}
// Try decrypting the index: this will verify the passphrase too
return Promise.resolve()
// First: derive the encryption key
.then(() => kdf(passphrase + appToken, keySalt))
.then((masterKey) => {
this._masterKey = masterKey
})
// Un-wrap the key
.then(() => UnwrapKey(this._masterKey, this._encryptedIndex.wrappedKey))
// Decrypt the index
.then((key) => Decrypt(key, this._encryptedIndex.iv, this._encryptedIndex.data, indexTag))
.then((data) => {
// Convert the buffer to string
const str = buf2str(new Uint8Array(data))
// Store the contents
this._contents = JSON.parse(str)
})
// Exceptions likely mean that the key/passphrase are wrong
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Error while unlocking the box:', err)
// Ensure the box remains locked
this.lock()
// Bubble up
throw Error('Failed to unlock to box')
})
}
}
================================================
FILE: app/lib/Credentials.js
================================================
import {RandomString} from './Utils'
import storage from './StorageService'
import IdTokenVerifier from 'idtoken-verifier'
/**
* During the authentication process we need to use nonce's to protect against certain kinds of attacks.
*/
class Nonce {
constructor() {
this._nonceKeyName = 'hereditas-nonce'
this._nonceLength = 7
}
/**
* Generates a new nonce and stores it in the session storage
*
* @returns {string} A nonce
*/
generate() {
// Generate a nonce
const nonce = RandomString(this._nonceLength)
// Store the nonce in the session
storage.sessionStorage.setItem(this._nonceKeyName, nonce)
return nonce
}
/**
* Retrieves the last nonce from session storage
*
* @returns {string} A nonce
*/
retrieve() {
const read = storage.sessionStorage.getItem(this._nonceKeyName)
const regExp = new RegExp('^[A-Za-z0-9_\\-]{' + this._nonceLength + '}$')
if (!read || !read.match(regExp)) {
return null
}
return read
}
}
/**
* Managed the authentication flow, and validates the JWT token.
*/
export class Credentials {
constructor() {
this._sessionKeyName = 'hereditas-jwt'
this._tokenValidated = false
this._nonce = new Nonce()
this._profile = null
}
/**
* Returns the authorization URL to point users to, storing the nonce
*
* @returns {string} Authorization URL
*/
authorizationUrl() {
// Generate a nonce
const nonce = this._nonce.generate()
// URL-encode the return URL
const appUrl = encodeURIComponent(window.location.href)
// Generate the URL
const authIssuer = process.env.AUTH_ISSUER
const authClientId = process.env.AUTH_CLIENT_ID
return `${authIssuer}/authorize?client_id=${authClientId}&response_type=id_token&redirect_uri=${appUrl}&scope=openid%20profile&nonce=${nonce}&response_mode=fragment`
}
/**
* Returns the profile object from the JWT token
*
* @returns {Object} Profile for the authenticated user
* @async
*/
async getProfile() {
// If we have a pre-parsed and pre-validated profile in memory, return that
if (this._profile) {
return this._profile
}
// Get the token
const jwt = this.getToken()
if (!jwt) {
return {}
}
// Get the profile out of the token
let profile
try {
profile = await this._validateToken(jwt)
if (!profile) {
profile = {}
}
this._profile = profile
return profile
}
catch (e) {
this._profile = {}
throw e
}
}
/**
* Returns the JWT token for the session
*
* @returns {string|null} JWT Token, or null if no token
*/
getToken() {
const read = storage.sessionStorage.getItem(this._sessionKeyName)
if (!read || !read.length) {
return null
}
let data
try {
data = JSON.parse(read)
}
catch (error) {
// eslint-disable-next-line no-console
console.error('Error while parsing JSON from sessionStorage', error)
throw Error('Could not get the token from session storage')
}
if (!data || !data.jwt) {
return null
}
return data.jwt
}
/**
* Stores the JWT token for the session
*
* @param {string} jwt - JWT Token
* @async
*/
async setToken(jwt) {
// Delete the profile in memory
this._profile = null
// First, validate the token
const profile = await this._validateToken(jwt)
if (!profile) {
throw Error('Token validation failed')
}
// Store the token
storage.sessionStorage.setItem(this._sessionKeyName, JSON.stringify({jwt}))
// Set the profile in memory
this._profile = profile
}
/**
* Validates a token
*
* @param {string} jwt - JWT token to validate
* @returns {Promise} Extracted payload
* @private
*/
async _validateToken(jwt) {
// Ensure issuer ends with /
const issuer = process.env.AUTH_ISSUER + (process.env.AUTH_ISSUER.charAt(process.env.AUTH_ISSUER.length - 1) != '/' ? '/' : '')
// Validate the token
const verifier = new IdTokenVerifier({
issuer,
audience: process.env.AUTH_CLIENT_ID
})
const payload = await new Promise((resolve, reject) => {
verifier.verify(jwt, this._nonce.retrieve(), (error, payload) => {
if (error) {
// eslint-disable-next-line no-console
console.error('Validation error', error)
return reject('Invalid token')
}
// Check if the payload contains the Hereditas namespace
if (!payload[process.env.ID_TOKEN_NAMESPACE]) {
// eslint-disable-next-line no-console
console.error('Token doesn\'t contain the Hereditas namespace')
return reject('Token doesn\'t contain the Hereditas namespace')
}
resolve(payload)
})
})
return payload
}
}
// The default export is an instance (singleton) of Credentials
const credentials = new Credentials()
export default credentials
================================================
FILE: app/lib/CryptoUtils.js
================================================
// Inspired by https://gist.github.com/tscholl2/dc7dc15dc132ea70a98e8542fefffa28#file-aes-js
/**
* Encodes a utf8 string as a byte array.
* @param {String} str
* @returns {Uint8Array}
*/
export function str2buf(str) {
return new TextEncoder('utf-8').encode(str)
}
/**
* Decodes a byte array as a utf8 string.
* @param {Uint8Array} buffer
* @returns {String}
*/
export function buf2str(buffer) {
return new TextDecoder('utf-8').decode(buffer)
}
/**
* Conctatenates two ArrayBuffer's
*
* @param {ArrayBuffer} buffer1 - First buffer
* @param {ArrayBuffer} buffer2 - Second buffer
* @returns {ArrayBuffer} The buffer with the data concatenated
*/
function concatBuffers(buffer1, buffer2) {
const result = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
result.set(new Uint8Array(buffer1), 0)
result.set(new Uint8Array(buffer2), buffer1.byteLength)
return result.buffer
}
/**
* Given a passphrase and a salt, this generates a crypto key
* using Argon2.
* @param {string} passphrase
* @param {ArrayBuffer} salt
* @returns {Promise}
* @async
*/
export function DeriveKeyArgon2(passphrase, salt) {
// Import argon2 dynamically to reduce bundle size, if it's not necessary
const saltArr = new Uint8Array(salt)
return import('argon2-browser')
.then((argon2) => argon2.hash({
pass: passphrase,
salt: saltArr,
type: argon2.ArgonType.Argon2id,
time: process.env.ARGON2_ITERATIONS,
mem: process.env.ARGON2_MEMORY,
hashLen: 32,
parallelism: 1
}))
.then((res) =>
window.crypto.subtle.importKey(
'raw',
res.hash,
{name: 'AES-KW', length: 256},
false,
['unwrapKey']
)
)
}
/**
* Given a passphrase and a salt, this generates a crypto key
* using `PBKDF2` with SHA-512 and N iterations.
* @param {string} passphrase
* @param {ArrayBuffer} salt
* @returns {Promise}
* @async
*/
export function DeriveKeyPBKDF2(passphrase, salt) {
return Promise.resolve()
.then(() =>
window.crypto.subtle.importKey(
'raw',
str2buf(passphrase),
'PBKDF2',
false,
['deriveKey']
)
)
.then((k) =>
window.crypto.subtle.deriveKey(
{name: 'PBKDF2', salt, iterations: process.env.PBKDF2_ITERATIONS, hash: 'SHA-512'},
k,
{name: 'AES-KW', length: 256},
false,
['unwrapKey']
)
)
}
/**
* Given a key and ciphertext (in the form of a string) as given by `encrypt`,
* this decrypts the ciphertext and returns the original plaintext
* @param {CryptoKey} key - Encryption key
* @param {ArrayBuffer} iv - IV
* @param {ArrayBuffer} data - Data to decrypt
* @param {ArrayBuffer} tag - AES-GCM tag
* @returns {Promise} Decrypted text as string
* @async
* @throws Throws an error if the decryption fails, likely meaning that the key was wrong.
*/
export function Decrypt(key, iv, data, tag) {
return window.crypto.subtle.decrypt(
{name: 'AES-GCM', iv},
key,
concatBuffers(data, tag)
)
}
/**
* Unwraps a key wrapped with AES-KW (per RFC 3349)
*
* @param {CryptoKey} wrappingKey - Key used to wrap/unwrap the key
* @param {ArrayBuffer} ciphertext - Wrapped key
* @returns {Promise} Unwrapped key
* @async
* @throws Throws an error if the decryption fails, likely meaning that the key was wrong.
*/
export function UnwrapKey(wrappingKey, ciphertext) {
return window.crypto.subtle.unwrapKey(
'raw',
ciphertext,
wrappingKey,
{name: 'AES-KW'},
{name: 'AES-GCM'},
false,
['decrypt']
)
.then((key) => {
return key
})
}
================================================
FILE: app/lib/StorageService.js
================================================
// This module is based on https://github.com/Acanguven/StorageService/blob/master/storage.js
// License: MIT https://github.com/Acanguven/StorageService/blob/master/LICENSE
/**
* This class allows access to localStorage and sessionStorage.
* If they are not supported in the current browser, automatically falls back to a cookie-based storage
*/
export class StorageService {
/**
* Initializes the object.
*/
constructor() {
this.localStorage = this._isSupported('localStorage') ?
window.localStorage :
new CookieStore()
this.sessionStorage = this._isSupported('sessionStorage') ?
window.sessionStorage :
new CookieStore(true)
}
/**
* Tests if the type of storage is supported in the current browser
*
* @param {"localStorage"|"sessionStorage"} type - Name of the storage to test
* @returns {boolean} True if the browser supports the kind of storage
* @private
*/
_isSupported(type) {
const testKey = '__isSupported'
const storage = window[type]
try {
storage.setItem(testKey, '1')
storage.removeItem(testKey)
return true
}
catch (error) {
return false
}
}
}
/**
* Interface that implements the protocol of localStorage/sessionStorage while keeping the data in memory.
*/
export class MemoryStore {
/**
* Initializes the object.
*/
constructor() {
this._store = {}
}
getItem(name) {
return this._store[name] || null
}
setItem(name, value) {
this._store[name] = value
}
removeItem(name) {
delete this._store[name]
}
}
/**
* Interface that implements the protocol of localStorage/sessionStorage while keeping the data in a cookie.
*/
export class CookieStore {
/**
* Initializes the object.
*
* @param {bool} isSessionStorage - True if this object is for session storage (controls cookies' expiry)
*/
constructor(isSessionStorage) {
this._objectStore = {}
this._expireDate = isSessionStorage ?
' path=/' :
' expires=Tue, 19 Jan 2038 03:14:07 GMT path=/'
this._updateObject()
}
getItem(name) {
return this._objectStore[name] || null
}
setItem(name, value) {
if (!name) {
return
}
document.cookie = escape(name) + '=' + escape(value) + this._expireDate
this._updateObject()
}
removeItem(name) {
if (!name) {
return
}
document.cookie = escape(name) + this._expireDate
delete this._objectStore[name]
}
_updateObject() {
const couples = document.cookie.split(/\s*\s*/)
for (let i = 0; i < couples.length; i++) {
const couple = couples[i].split(/\s*=\s*/)
if (couple.length > 1) {
const key = unescape(couple[0])
this._objectStore[key] = unescape(couple[1])
}
}
}
}
const storage = new StorageService()
export default storage
================================================
FILE: app/lib/Utils.js
================================================
/**
* Returns a random string, useful for example as nonce.
*
* @param {number} length - Length of the string
* @returns {string} Random string
*/
export function RandomString(length = 7) {
const bytes = new Uint8Array(length)
const random = window.crypto.getRandomValues(bytes)
const result = []
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-_'
random.forEach((c) => {
result.push(charset[c % charset.length])
})
return result.join('')
}
/**
* Returns a Promise that resolves after a certain amount of time, in ms
*/
export function WaitPromise(time) {
return new Promise((resolve) => {
setTimeout(resolve, time || 0)
})
}
================================================
FILE: app/main.css
================================================
/* Tailwind */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Default styles */
h1 {
@apply text-2xl mb-2;
}
h2 {
@apply text-xl mb-2;
}
h3 {
@apply text-lg mb-2;
}
a {
@apply text-blue-600 underline;
}
/* Rendered content */
section.rendered {
@apply my-2 mx-4 px-3 py-1 bg-white border border-blue-500;
}
.rendered h1, .rendered h2, .rendered h3, .rendered h4, .rendered h5, .rendered h6 {
@apply mt-4 mb-2;
}
.rendered p {
@apply my-2;
}
.rendered pre {
@apply mx-2;
}
.rendered code {
@apply w-full whitespace-pre-wrap text-sm;
}
.rendered ul {
@apply list-disc list-inside;
}
.rendered ol {
@apply list-decimal list-inside;
}
.rendered ul li, .rendered ol li {
@apply pl-6;
}
.rendered blockquote {
@apply italic px-6 text-sm;
}
================================================
FILE: app/main.html
================================================
Hereditas
================================================
FILE: app/main.js
================================================
// Style
import './main.css'
// JavaScript modules
import App from './layout/App.svelte'
import credentials from './lib/Credentials'
import qs from 'qs'
import {Box} from './lib/Box'
// Import stores
import {profile, hereditasProfile, box, authError} from './stores'
function getHash() {
let hash = window.location.hash
if (hash && hash.length > 2) {
// Remove the leading # and / characters
if (hash.charAt(0) == '#') {
hash = hash.substr(1)
}
if (hash.charAt(0) == '/') {
hash = hash.substr(1)
}
const parsed = qs.parse(hash, {
depth: 1,
parameterLimit: 20,
ignoreQueryPrefix: true,
})
// Remove the information from the URL (for security, in case it contains an id_token)
history.replaceState(undefined, undefined, '#')
return parsed
}
else {
return null
}
}
function checkAuthError(hash) {
// Check if we have an error from the authentication server
if (hash && hash.error) {
// Check for the error type
if (hash.error == 'unauthorized') {
return hash.error_description || 'Unauthorized'
}
else {
return hash.error_description || hash.error
}
}
return null
}
async function handleSession(hash) {
// Check if we have an id_token
if (hash && hash.id_token) {
// Validate and store the JWT
// If there's an error, redirect to auth page
try {
await credentials.setToken(hash.id_token)
}
catch (error) {
// eslint-disable-next-line no-console
console.error('Token error', error)
throw Error('Token error')
}
}
// If we have credentials stored, redirect the user to the authentication page
if (!credentials.getToken()) {
return false
}
// Get the profile
// If there's no session or it has expired, redirect to auth page
let profileData
try {
profileData = await credentials.getProfile()
}
catch (error) {
// eslint-disable-next-line no-console
console.error('Token error', error)
throw Error('Token error')
}
return profileData
}
const app = (async function() {
let _profile
let _hereditasProfile = null
let _box = null
// Parse the hash if any
const hash = getHash()
// Check if we have an error from the authentication server
let unrecoverableError = checkAuthError(hash)
if (!unrecoverableError) {
// Load profile and check session
try {
_profile = await handleSession(hash)
}
catch (err) {
_profile = null
unrecoverableError = err
}
// Hereditas profile (from the profile)
if (_profile) {
// Hereditas profile (from the profile)
_hereditasProfile = _profile[process.env.ID_TOKEN_NAMESPACE] || {}
// Check if we have an app token
if (_hereditasProfile.token) {
try {
// Create a new box and fetch the index
_box = new Box()
// Fetch the index asynchronously and do not wait for completion
_box.fetchIndex()
}
catch (err) {
// eslint-disable-next-line no-console
console.error('Error while requesting box\'s data', err)
}
}
}
}
// Store the profile, hereditasProfile and box into Svelte stores
profile.set(_profile)
hereditasProfile.set(_hereditasProfile)
box.set(_box)
authError.set(unrecoverableError)
// Crete a Svelte app by loading the main view
return new App({
target: document.body
})
})()
export default app
================================================
FILE: app/postcss.config.js
================================================
const path = require('path')
const production = !process.env.ROLLUP_WATCH
module.exports = {
plugins: [
require('postcss-import')(),
require('tailwindcss')(path.resolve(__dirname, 'tailwind.config.js')),
require('autoprefixer'),
...(production ? [require('@fullhuman/postcss-purgecss')({
// Specify the paths to all of the template files in your project
content: [
path.resolve(__dirname, 'main.html'),
path.resolve(__dirname, '**/*.svelte'),
path.resolve(__dirname, '**/*.html'),
],
// Whitelist styles that might be in the content generated from markdown
whitelist: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'strong', 'b', 'em', 'i', 'a', 'img', 'pre', 'code', 'hr', 'blockquote'],
// Include any special characters you're using in this regular expression
defaultExtractor: content => content.match(/[\w-/:]+(?
home
>
{#each content.path as path, i}
{path}
>
{/each}
{content.name}
{#if content.display == 'text'}
{:else if content.display == 'html'}
{:else if content.display == 'image'}
{:else}
Download: {content.name}
{/if}
{:catch error}
Error: {error}
{/await}
================================================
FILE: app/views/ListView.svelte
================================================
{#if list.paths && list.paths.length}
home
>
{#each list.paths as path, i (path)}
{#if i == (list.paths.length - 1)}
{path}
{:else}
{path}
>
{/if}
{/each}
{:else}
home
{/if}
Name
{#each list.folders as folder (folder)}
{folder}
{/each}
{#each list.files as file (file.dist)}
{file.name}
{/each}
================================================
FILE: app/views/UnlockView.svelte
================================================
{#if $profile}
{:else}
{/if}
About this page
================================================
FILE: app/webpack.config.js
================================================
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const SriPlugin = require('webpack-subresource-integrity')
const CopyPlugin = require('copy-webpack-plugin')
const {DefinePlugin} = require('webpack')
const path = require('path')
const fs = require('fs')
const marked = require('marked')
const mode = process.env.NODE_ENV || 'production'
const prod = mode === 'production'
const htmlMinifyOptions = {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: true,
collapseBooleanAttributes: true,
decodeEntities: true,
html5: true,
keepClosingSlash: false,
processConditionalComments: true,
removeEmptyAttributes: true
}
// Welcome content
let welcomeContent = ''
if (fs.existsSync('welcome.md')) {
let welcomeMarkdown = fs.readFileSync('welcome.md', 'utf8')
// Remove the front matter, if any
if (welcomeMarkdown.startsWith('---')) {
welcomeMarkdown = welcomeMarkdown.replace(/^---$.*^---$/ms, '')
}
welcomeContent = marked(welcomeMarkdown)
}
/**
* Returns a configuration object for webpack
*
* @param {Object} appParams - Params for the application
* @returns {Object} Configuration object for webpack
*/
function webpackConfig(appParams) {
return {
entry: {
hereditas: [path.resolve(__dirname, 'main.js')],
},
resolve: {
mainFields: ['svelte', 'browser', 'module', 'main'],
extensions: ['.mjs', '.js', '.svelte'],
modules: [path.resolve(__dirname, '../node_modules')]
},
resolveLoader: {
modules: [path.resolve(__dirname, '../node_modules')]
},
output: {
path: path.resolve(process.cwd(), appParams.distDir),
filename: '[name].[hash].js',
chunkFilename: '[name].[id].[hash].js',
crossOriginLoading: 'anonymous'
},
module: {
// Do not parse wasm files
noParse: /\.wasm$/,
rules: [
{
test: /\.(svelte)$/,
exclude: [],
use: {
loader: 'svelte-loader',
options: {
emitCss: true,
}
}
},
{
test: /\.css$/,
use: [
'style-loader',
{loader: 'css-loader', options: {importLoaders: 1}},
'postcss-loader',
]
},
{
test: /\.wasm$/,
loaders: ['base64-loader'],
type: 'javascript/auto'
}
]
},
// Fixes for argon2-browser
node: {
__dirname: false,
fs: 'empty',
Buffer: false,
process: false
},
mode,
plugins: [
// Constants
new DefinePlugin({
'process.env.AUTH_ISSUER': JSON.stringify(appParams.authIssuer),
'process.env.AUTH_CLIENT_ID': JSON.stringify(appParams.authClientId),
'process.env.ID_TOKEN_NAMESPACE': JSON.stringify(appParams.idTokenNamespace),
'process.env.KEY_SALT': JSON.stringify(appParams.keySalt.toString('base64')),
'process.env.INDEX_TAG': JSON.stringify(appParams.indexTag.toString('base64')),
'process.env.KEY_DERIVATION_FUNCTION': JSON.stringify(appParams.kdf),
'process.env.PBKDF2_ITERATIONS': JSON.stringify(appParams.pbkdf2Iterations),
'process.env.ARGON2_ITERATIONS': JSON.stringify(appParams.argon2Iterations),
'process.env.ARGON2_MEMORY': JSON.stringify(appParams.argon2Memory),
'process.env.WELCOME_MD': JSON.stringify(welcomeContent)
}),
// Extract CSS
new MiniCssExtractPlugin({
filename: '[name].[hash].css'
}),
// Generate the index.html file
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, 'main.html'),
chunks: ['hereditas'],
minify: prod ? htmlMinifyOptions : false
}),
// Enable subresource integrity check
new SriPlugin({
hashFuncNames: ['sha384'],
enabled: prod,
}),
// Copy files
new CopyPlugin({
patterns: [
{from: path.resolve(__dirname, 'robots.txt'), to: ''},
]
}),
],
devtool: prod ? false : 'source-map',
performance: {
// 400 KB (up from default 250 KB)
maxEntrypointSize: 400000
}
}
}
module.exports = webpackConfig
================================================
FILE: auth0/01-whitelist.js
================================================
function (user, context, callback) {
// Apply this rule only for Hereditas, and bypass it for other apps
context.clientMetadata = context.clientMetadata || {};
if (!context.clientMetadata.hereditas) {
return callback(null, user, context);
}
// List of authorized users
const whitelist = /*%ALL_USERS%*/;
// Access should only be granted to verified users.
if (!user.email || !user.email_verified) {
return callback(new UnauthorizedError('Access denied.'));
}
// Check if the user's email address is whitelisted
const userHasAccess = whitelist.some((email) => email === user.email);
if (!userHasAccess) {
return callback(new UnauthorizedError('Access denied.'));
}
// Continue
callback(null, user, context);
}
================================================
FILE: auth0/02-notify.js
================================================
function (user, context, callback) {
// Apply this rule only for Hereditas, and bypass it for other apps
context.clientMetadata = context.clientMetadata || {};
if (!context.clientMetadata.hereditas) {
return callback(null, user, context);
}
// Skip if there's no webhook
if (!configuration || !configuration.WEBHOOK_URL || configuration.WEBHOOK_URL === '0') {
return callback(null, user, context);
}
// List of owners
const owners = /*%OWNERS%*/;
// Trigger the webhook
const role = (owners.some((email) => email === user.email)) ? 'owner' : 'user';
const body = {
value1: `New Hereditas login on ${(new Date()).toUTCString()}. User: ${user.email} (role: ${role})`,
value2: user.email,
value3: role
};
const fetch = require('node-fetch@2.6.0');
fetch(configuration.WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'}
})
// Ensure the response has a valid status code
.then((response) => {
if (response.ok) {
return callback(null, user, context);
} else {
return Promise.reject('Invalid response status code');
}
})
// Catch errors and fail (fail the login even if the notification fails to send)
.catch((err) => {
console.error(err);
callback(new Error('Error sending the notification'));
});
}
================================================
FILE: auth0/03-wait-logic.js
================================================
function (user, context, callback) {
// Apply this rule only for Hereditas, and bypass it for other apps
context.clientMetadata = context.clientMetadata || {};
if (!context.clientMetadata.hereditas) {
return callback(null, user, context);
}
// List of owners
const owners = /*%OWNERS%*/;
// Get the Auth0 management client
const ManagementClient = require('auth0@2.27.0').ManagementClient;
const management = new ManagementClient({
domain: auth0.domain,
clientId: configuration.AUTH0_CLIENT_ID,
clientSecret: configuration.AUTH0_CLIENT_SECRET
});
// Get metadata
const requestTime = context.clientMetadata.requestTime ? parseInt(context.clientMetadata.requestTime, 10) : 0;
const waitTime = parseInt(context.clientMetadata.waitTime, 10);
// Check if the user is an owner
const isOwner = owners.some((email) => email === user.email);
if (isOwner) {
// Enrich the JWT with the app token
if (context.idToken) {
context.idToken['https://hereditas.app'] = {
role: 'owner',
token: configuration.APP_TOKEN,
requestTime: 0,
waitTime: waitTime
};
}
// Reset the timer if it's running
if (requestTime > 0) {
const params = {client_id: context.clientID};
const data = {client_metadata: {requestTime: '0'}};
management.clients.update(params, data, (err, client) => {
if (err) {
console.log(err);
callback(new Error('Error while updating client_metadata'));
}
else {
// Continue
callback(null, user, context);
}
});
}
else {
// Continue
callback(null, user, context);
}
}
else {
const now = parseInt(Date.now() / 1000, 10);
// For non-owners: first, check if the timer has been started already, and we've reached the wait time
if (requestTime > 0) {
// Enrich the JWT with the app token
if (context.idToken) {
// If the wait time has passed, add the token
const token = ((requestTime + waitTime) < now) ?
configuration.APP_TOKEN :
null;
// Enrich the JWT
context.idToken['https://hereditas.app'] = {
role: 'user',
token: token,
requestTime: requestTime,
waitTime: waitTime
};
}
// Continue
callback(null, user, context);
}
else {
// Start the timer
const params = {client_id: context.clientID};
const data = {client_metadata: {requestTime: now.toString()}};
management.clients.update(params, data, (err, client) => {
if (err) {
console.log(err);
callback(new Error('Error while updating client_metadata'));
}
else {
// Enrich the JWT with the app token
if (context.idToken) {
context.idToken['https://hereditas.app'] = {
role: 'user',
requestTime: now,
waitTime: waitTime
};
}
// Continue
callback(null, user, context);
}
});
}
}
}
================================================
FILE: bin/run
================================================
#!/usr/bin/env node
require('@oclif/command')
.run()
.then(require('@oclif/command/flush'))
.catch(require('@oclif/errors/handle'))
================================================
FILE: bin/run.cmd
================================================
@echo off
node "%~dp0\run" %*
================================================
FILE: cli/commands/auth0/sync.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../../lib/Config')
const Auth0Management = require('../../lib/Auth0Management')
class Auth0SetupCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Initialize the management client
const auth0Management = new Auth0Management(config)
// First step: sync the app on Auth0
const clientId = await auth0Management.syncClient(config.get('auth0.hereditasClientId'))
config.set('auth0.hereditasClientId', clientId)
// Second step: create the rules
const ruleIds = await auth0Management.syncRules(config.get('auth0.rules'))
config.set('auth0.rules', ruleIds)
// Third step: create rule settings
await auth0Management.updateRulesConfigs()
// Save config changes
await config.save()
this.log('Auth0 configuration updated successfully')
}
}
// Command description
Auth0SetupCommand.description = `sync the application and rules in Auth0
Synchronizes the status of the resources configured in Auth0: the client (application), the rules and the rule settings.
`
module.exports = Auth0SetupCommand
================================================
FILE: cli/commands/build.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../lib/Config')
const Builder = require('../lib/Builder')
const {cli} = require('cli-ux')
class BuildCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Make sure that we have an Auth0 client id
const clientId = config.get('auth0.hereditasClientId')
if (!clientId) {
this.error('The Hereditas application hasn\'t been configured on Auth0 yet. Please run `hereditas auth0:sync` first')
return this.exit(1)
}
// Check if we have a passphrase passed as environmental variable (useful for development only)
let passphrase
if (process.env.HEREDITAS_PASSPHRASE) {
passphrase = process.env.HEREDITAS_PASSPHRASE
this.warn('Passphrase set through the HEREDITAS_PASSPHRASE environmental variable; this should be used for development only')
}
else {
// Ask for the user passphrase
passphrase = await cli.prompt('User passphrase', {type: 'mask'})
}
if (!passphrase || passphrase.length < 8) {
this.error('Passphrase needs to be at least 8 characters long')
return this.exit(1)
}
// Timer
const startTime = Date.now()
// Build the project
const builder = new Builder(passphrase, config)
await builder.build()
// Done!
const duration = (Date.now() - startTime) / 1000
if (!builder.hasErrors) {
this.log(`Finished building project in ${config.get('distDir')} (took ${duration} seconds)`)
}
else {
this.error(`Build failed (took ${duration} seconds)`)
}
}
}
// Command description
BuildCommand.description = `build an Hereditas project
Build an Hereditas project in the current working directory.
`
module.exports = BuildCommand
================================================
FILE: cli/commands/init.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const fs = require('fs')
const util = require('util')
const process = require('process')
const path = require('path')
const Config = require('../lib/Config')
const {GenerateToken} = require('../lib/Crypto')
class InitCommand extends Command {
async run() {
const {flags} = this.parse(InitCommand)
// Check if the folder is empty
const files = await util.promisify(fs.readdir)('.')
if (files.length) {
this.error(`Directory ${process.cwd()} isn't empty; aborting`)
return this.exit(1)
}
// Get the relative paths to the folders
const contentDir = path.relative('', flags.content)
const distDir = path.relative('', flags.dist)
// Create the directories
const mkdirPromise = util.promisify(fs.mkdir)
await mkdirPromise(contentDir)
await mkdirPromise(distDir)
// Generate an appToken
const appToken = await GenerateToken(21)
// Create configuration
const config = new Config('hereditas.json')
config.create({
distDir: distDir,
contentDir: contentDir,
auth0: {
domain: flags.auth0Domain,
managementClientId: flags.auth0ClientId,
managementClientSecret: flags.auth0ClientSecret
},
urls: flags.url,
waitTime: 86400,
appToken
})
await config.save()
// Create the welcome.md file
const welcomeContent = `---
# This welcome file is displayed in the box's authentication page.
# It can be used to provide information to visitors about what this Hereditas box is, and how it can be used.
# Note that this file is NOT ENCRYPTED, and it's accessible to the entire world; do not write anything confidential in here.
---
## What is this?
Someone (likely, a loved one) told you to come here in case they suddenly disappeared. This box contains important information about the digital life of the person that shared it with you, for example passwords, digital documents, photos, cryptocurrency wallets, etc.
## How do I use this?
Sign in above using your existing account. You will then need to type the passphrase that you've been given.
Unless you're the owner of this box, you won't immediately have access to its content, but instead you'll have to wait a certain amount of time.
During that time, if the owner signs in here too, they will reset the timer and you will not get access to this box.
## About Hereditas
[Hereditas](https://hereditas.app) is an open source project to generate "fully-trustless" digital legacy boxes.
`
await util.promisify(fs.writeFile)(path.relative('', 'welcome.md'), welcomeContent)
this.log('Project initialized')
}
}
// Command description
InitCommand.description = `initialize a new Hereditas project in the current working directory.
Initialize a new Hereditas project in the current working directory, creating the folders for the content and the generated data, as well as the "hereditas.json" configuration file.
The current working directory needs to be empty, or the command will raise an error.
`
// Usage example
InitCommand.usage = `init \\
--auth0Domain "yourdomain.auth0.com" \\
--auth0ClientId "..." \\
--auth0ClientSecret "..." \\
--url "https://my.testhereditas.app"
`
// Command-line options
InitCommand.flags = {
content: flags.string({
char: 'i',
description: 'path of the directory with the content',
default: 'content'
}),
dist: flags.string({
char: 'o',
description: 'path of the dist directory (where output is saved)',
default: 'dist'
}),
auth0Domain: flags.string({
char: 'd',
description: 'Auth0 domain/tenant (e.g. "myhereditas.auth0.com")',
required: true
}),
auth0ClientId: flags.string({
char: 'c',
description: 'Auth0 client ID for the management app',
required: true
}),
auth0ClientSecret: flags.string({
char: 's',
description: 'Auth0 client secret for the management app',
required: true
}),
url: flags.string({
char: 'u',
description: 'URL where the app is deployed to, used for OAuth callbacks (multiple values supported)',
required: true,
multiple: true
})
}
module.exports = InitCommand
================================================
FILE: cli/commands/pack.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../lib/Config')
const util = require('util')
const path = require('path')
const fs = require('fs')
const {CleanDirectory} = require('../lib/Utils')
const execPromise = util.promisify(require('child_process').exec)
class PackCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Make sure that http://localhost:8080 is allowed as url
let urls = config.get('urls')
if (!urls || urls.indexOf('http://localhost:8080') == -1) {
this.error('Before you can pack a box, the URL `http://localhost:8080` must be allowed. Please run `hereditas url:add -u http://localhost:8080` (and then `hereditas auth0:sync`).')
return this.exit(1)
}
// Check that we have go installed
try {
const {stdout} = await execPromise('go version')
const match = stdout.match(/^go version go1\.([0-9]+)/)
if (!match || !match[0] || !match[1]) {
throw Error('Invalid go interpreter')
}
const goVersion = parseInt(match[1], 10)
if (goVersion < 13) {
throw Error('Go 1.13 or higher is required')
}
}
catch (err) {
this.error('Go 1.13 or higher must be installed for this command to work.')
return this.exit(1)
}
// Ensure that the GOPATH and HOME are defined
if (!process.env.GOPATH || !process.env.HOME) {
this.error('Environmental variables GOPATH and HOME must be defined.')
return this.exit(1)
}
// Check that we have packr2 installed
try {
const {stdout} = await execPromise('packr2 version')
const match = stdout.match(/^v2/)
if (!match || !match[0]) {
throw Error('Invalid packr2 version')
}
}
catch (err) {
this.error('packr2 must be installed for this command to work.\nSee https://github.com/gobuffalo/packr/tree/master/v2')
return this.exit(1)
}
// Check that the Hereditas box is built
const distDir = config.get('distDir')
if (!fs.existsSync(path.join(distDir, '_index'))) {
this.error('This Hereditas box hasn\'t been built yet; please run `hereditas build` first.')
return this.exit(1)
}
// Create a directory for the Go app
// Or clean it if it exists
const packPath = path.relative('', 'pack.tmp')
if (fs.existsSync(packPath)) {
await CleanDirectory(packPath)
}
else {
fs.mkdirSync(packPath)
}
// Copy the Go app's files
['main.go', 'go.mod', 'go.sum'].forEach((file) => {
fs.copyFileSync(
path.resolve(__dirname, '../../pack/' + file),
path.join(packPath, file)
)
})
// Create a symlink to distDir inside the packPath
fs.symlinkSync(path.join('..', distDir), path.join(packPath, 'dist'), 'dir')
// Run packr2
await execPromise('packr2', {
cwd: packPath
})
// Build the Go app for all archs
const archs = {
'linux-amd64': {
GOOS: 'linux',
GOARCH: 'amd64'
},
'linux-386': {
GOOS: 'linux',
GOARCH: '386'
},
'linux-arm64': {
GOOS: 'linux',
GOARCH: 'arm64'
},
'linux-armv7': {
GOOS: 'linux',
GOARCH: 'arm',
GOARM: '7'
},
'macos': {
GOOS: 'darwin',
GOARCH: 'amd64'
},
'win64.exe': {
GOOS: 'windows',
GOARCH: 'amd64'
},
'win32.exe': {
GOOS: 'windows',
GOARCH: '386'
}
}
for (const extension in archs) {
if (!archs.hasOwnProperty(extension)) {
continue
}
const file = 'hereditas-box-' + extension
this.log('Building ' + file)
// Environmental variables
const env = Object.assign({
GOPATH: process.env.GOPATH,
HOME: process.env.HOME,
CGO_ENABLED: '0',
GO111MODULE: 'on'
}, archs[extension])
await execPromise('go build -o ' + path.join('..', '_bin', file), {
cwd: packPath,
env
})
}
// Delete the temporary folder
await CleanDirectory(packPath)
fs.rmdirSync(packPath)
this.log('Done! Binaries are in the _bin folder')
}
}
// Command description
PackCommand.description = `pack a box into a self-contained binary
After building a box with Hereditas, the \`hereditas pack\` command allows you to generate a self-contained binary (for Windows, macOS and Linux) that contains your Hereditas box and all of its contents. Depending on your use case, this single binary might be easier to distribute.
Note that this command has some pre-requisites:
- You need to have the Go compiler installed
(version 1.13 or higher)
- You need to have packr2 installed in your path
(see https://github.com/gobuffalo/packr/tree/master/v2)
- Your Hereditas box must be already built
(run \`hereditas build\` beforehand)
- The URL \`http://localhost:8080\` must be allowed for this box
(run \`hereditas url:add -u http://localhost:8080\`)
`
module.exports = PackCommand
================================================
FILE: cli/commands/regenerate-token.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../lib/Config')
const {GenerateToken} = require('../lib/Crypto')
class RegenerateTokenCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Generate a new appToken and save it
const appToken = await GenerateToken(21)
config.set('appToken', appToken)
await config.save()
this.log('New application token saved in the configuration file')
// Notify users that they need to run the auth0:sync command
this.log('Info: The new application token will be used for boxes you build from now on, using `hereditas build`; it will not impact existing boxes. Additionally, remember to run `hereditas auth0:sync` to update the application token on Auth0 after deploying the new box.')
}
}
// Command description
RegenerateTokenCommand.description = `regenerate the application token
Update the "application token", which is part of the encryption key, in the hereditas.json config file, by generating a new random one.
After running this command, you will need to build a new box with \`hereditas build\` and then synchronize the changes on Auth0 with \`hereditas auth0:sync\`.
`
module.exports = RegenerateTokenCommand
================================================
FILE: cli/commands/url/add.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class UrlAddCommand extends Command {
async run() {
const {flags} = this.parse(UrlAddCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Load the current list
let urls = config.get('urls')
if (!urls) {
urls = []
}
// Add all new URLs
for (let i = 0; i < flags.url.length; i++) {
if (urls.indexOf(flags.url[i]) != -1) {
this.log(`URL ${flags.url[i]} is already present`)
}
else {
// Add url
urls.push(flags.url[i])
this.log(`Added URL ${flags.url[i]}`)
}
}
// Save changes
config.set('urls', urls)
await config.save()
this.log('URL list updated')
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
UrlAddCommand.description = `add URLs where the box is deployed to, used for OAuth callbacks
Add one or more URLs to the list of addresses where the Hereditas box is deployed to. This information is stored on Auth0 to whitelist URLs where users are redirected after a successful authentication. Note that the protocol (\`http://\` or \`https://\`) needs to match too.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
UrlAddCommand.usage = `url:add \\
--url "https://my.testhereditas.app"
`
// Command-line options
UrlAddCommand.flags = {
url: flags.string({
char: 'u',
description: 'URL where the box is deployed to (multiple values supported)',
required: true,
multiple: true
})
}
module.exports = UrlAddCommand
================================================
FILE: cli/commands/url/list.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../../lib/Config')
class UrlListCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Load all urls
let urls = config.get('urls')
if (!urls) {
urls = []
}
this.log(urls.join('\n'))
}
}
// Command description
UrlListCommand.description = `list URLs where the box is deployed to
Shows the list of URLs where the Hereditas box is deployed to. This list is used by Auth0 to whitelist redirect URLs after users authenticate.
`
module.exports = UrlListCommand
================================================
FILE: cli/commands/url/rm.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class UrlRmCommand extends Command {
async run() {
const {flags} = this.parse(UrlRmCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Load the current list
let urls = config.get('urls')
if (!urls) {
urls = []
}
else {
// Remove urls that match
urls = urls.filter((el) => flags.url.indexOf(el) == -1)
}
if (!urls.length) {
this.error('Cannot remove all URLs from the list')
return this.exit(1)
}
// Save changes
config.set('urls', urls)
await config.save()
this.log('URL list updated')
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
UrlRmCommand.description = `removes URL(s) from the configuration
These URLs are used by Auth0 to whitelist the pages users are redirected to after authenticating.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
UrlRmCommand.usage = `url:rm \\
--url "https://my.testhereditas.app"
`
// Command-line options
UrlRmCommand.flags = {
url: flags.string({
char: 'u',
description: 'URL to remove (multiple values supported)',
required: true,
multiple: true
})
}
module.exports = UrlRmCommand
================================================
FILE: cli/commands/user/add.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class UserAddCommand extends Command {
async run() {
const {flags} = this.parse(UserAddCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Check if the user is already in the configuration
let users = config.get('users')
if (!users) {
users = []
}
const added = users.some((el) => el.email == flags.email)
if (added) {
this.log(`User ${flags.email} is already authorized`)
return
}
// Add user
users.push({
email: flags.email,
role: flags.role
})
config.set('users', users)
await config.save()
this.log(`Added user ${flags.email} (role: ${flags.role})`)
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
UserAddCommand.description = `add an authorized user to the box
Whitelist email addresses to allow users to authenticate and access your Hereditas box. If you configure Auth0 to enable social logins (e.g. Google, Facebook and/or Microsoft accounts), users won't need to set up a new account or password, and they can authenticate with their existing social account as long as the email address matches what you've whitelisted.
When you whitelist an email address, you can choose between the "user" role (the default) and the "owner" one. Someone with the "owner" role can access the data in this Hereditas box at any time (provided they have the "user passphrase" too), and when they authenticate, they reset any timer that might have been started by another person with the "user" role.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
UserAddCommand.usage = `user:add \\
--email "someone@example.com"
`
// Command-line options
UserAddCommand.flags = {
email: flags.string({
char: 'e',
description: 'email address of the user to whitelist',
required: true
}),
role: flags.string({
char: 'r',
options: ['user', 'owner'],
description: 'role: user or owner',
default: 'user'
})
}
module.exports = UserAddCommand
================================================
FILE: cli/commands/user/list.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class UserListCommand extends Command {
async run() {
const {flags} = this.parse(UserListCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Load all users
let users = config.get('users')
if (!users) {
users = []
}
const list = {owners: [], users: []}
for (let i = 0; i < users.length; i++) {
if (users[i].role == 'owner') {
list.owners.push(users[i].email)
}
else {
list.users.push(users[i].email)
}
}
list.owners.sort()
list.users.sort()
// Show list
if (!flags.role) {
this.log(`\x1b[1mOwners:\x1b[0m\n ${list.owners.join('\n ')}`)
this.log(`\x1b[1mUsers:\x1b[0m\n ${list.users.join('\n ')}`)
}
else {
this.log(list[flags.role + 's'].join('\n'))
}
}
}
// Command description
UserListCommand.description = `list users that are authorized to authenticate with this box
Prints the list of authorized users (email adddresses) and their role.
`
// Command-line options
UserListCommand.flags = {
role: flags.string({
char: 'r',
options: ['', 'user', 'owner'],
description: 'filter by role: user or owner (or none)',
default: ''
})
}
module.exports = UserListCommand
================================================
FILE: cli/commands/user/rm.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class UserRmCommand extends Command {
async run() {
const {flags} = this.parse(UserRmCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Load users and remove the requested one
let users = config.get('users')
if (!users) {
users = []
}
else {
users = users.filter((el) => el.email != flags.email)
}
// Save
config.set('users', users)
await config.save()
this.log(`Removed user ${flags.email}`)
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
UserRmCommand.description = `remove an authorized user from this box
Removes an email address from the list of those authorized to authenticate with Auth0 for this Hereditas box.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
UserRmCommand.usage = `user:rm \\
--email "someone@example.com"
`
// Command-line options
UserRmCommand.flags = {
email: flags.string({
char: 'e',
description: 'email address of the user to remove from the whitelist',
required: true
})
}
module.exports = UserRmCommand
================================================
FILE: cli/commands/wait-time/get.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../../lib/Config')
class WaitTimeGetCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
this.log(config.get('waitTime') + 's')
}
}
// Command description
WaitTimeGetCommand.description = `get the current value for the wait time
This command returns the current value for the wait time, in seconds.
The wait time is the amount of time for normal users (that don't have the "owner" role) before they can unlock the Hereditas box. Auth0 will not provide users with the "application token" unless the wait time has passed since their first login, preventing them from having the information required to unlock the Hereditas box. If users with the "owner" role authenticate, the timer is automatically stopped.
`
module.exports = WaitTimeGetCommand
================================================
FILE: cli/commands/wait-time/set.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class WaitTimeSetCommand extends Command {
async run() {
const {flags} = this.parse(WaitTimeSetCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Get the new value
const time = parseInt(flags.time || '0', 10)
if (time < 1) {
this.error('Wait time must be a number greater than zero')
return this.exit(1)
}
// Save changes
config.set('waitTime', time)
await config.save()
this.log('Wait time updated')
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
WaitTimeSetCommand.description = `configure the wait time
This command sets the wait time (in seconds) for this Hereditas box.
The wait time is the amount of time for normal users (that don't have the "owner" role) before they can unlock the Hereditas box. Auth0 will not provide users with the "application token" unless the wait time has passed since their first login, preventing them from having the information required to unlock the Hereditas box. If users with the "owner" role authenticate, the timer is automatically stopped.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
WaitTimeSetCommand.usage = `wait-time:set \\
--time 86400
`
// Command-line options
WaitTimeSetCommand.flags = {
time: flags.string({
char: 't',
description: 'wait time, in seconds',
required: true,
})
}
module.exports = WaitTimeSetCommand
================================================
FILE: cli/commands/webhook/get.js
================================================
'use strict'
const {Command} = require('@oclif/command')
const Config = require('../../lib/Config')
class WebhookGetCommand extends Command {
async run() {
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
this.log(config.get('webhookUrl') || 'No webhook configured')
}
}
// Command description
WebhookGetCommand.description = `get the current value for the webhook URL
Hereditas configures Auth0 to send a notification when someone successfully authenticates into this Hereditas box. The notification can be used as a warning that the timer has started.
Notifications are sent by invoking a webhook, which can then trigger any action you desire. See the Hereditas documentation for examples and ideas on how to use this feature.
If no webhook is set, Hereditas will not send you notifications on new logins.
`
module.exports = WebhookGetCommand
================================================
FILE: cli/commands/webhook/set.js
================================================
'use strict'
const {Command, flags} = require('@oclif/command')
const Config = require('../../lib/Config')
class WebhookSetCommand extends Command {
async run() {
const {flags} = this.parse(WebhookSetCommand)
// Read the config file
const config = new Config('hereditas.json')
try {
await config.load()
}
catch (e) {
this.error(`The current directory ${process.cwd()} doesn't contain a valid Hereditas project`)
return this.exit(1)
}
// Save changes
config.set('webhookUrl', (flags.url === 'none') ? undefined : flags.url)
await config.save()
this.log('Webhook URL updated')
// Notify users that they need to run the auth0:sync command
this.log('Info: The configuration has been updated locally; for changes to be effective, remember to run `hereditas auth0:sync`')
}
}
// Command description
WebhookSetCommand.description = `set the webhook URL used to notify of new logins
Hereditas configures Auth0 to send a notification when someone successfully authenticates into this Hereditas box. The notification can be used as a warning that the timer has started.
Notifications are sent by invoking a webhook, which can then trigger any action you desire. See the Hereditas documentation for examples and ideas on how to use this feature.
You can disable notifications by setting \`--url none\` when invoking this command.
After running this command, you will need to synchronize the changes on Auth0 with \`hereditas auth0:sync\` (it's not necessary to re-build or re-deploy the box).
`
// Usage example
WebhookSetCommand.usage = `webhook:set \\
--url "https://example.com/webhook/token/abc123XYZ"
`
// Command-line options
WebhookSetCommand.flags = {
url: flags.string({
char: 'u',
description: 'webhook URL; set to "none" to remove',
required: true,
})
}
module.exports = WebhookSetCommand
================================================
FILE: cli/index.js
================================================
module.exports = require('@oclif/command')
================================================
FILE: cli/lib/Auth0Management.js
================================================
'use strict'
const fs = require('fs')
const util = require('util')
const path = require('path')
const ManagementClient = require('auth0').ManagementClient
/**
* Configures Auth0 to work with Hereditas
*/
class Auth0Management {
/**
* Initializes the object
* @param {Config} config - Config object
*/
constructor(config) {
// Ensure that the configuration has Auth0 credentials
const auth0Config = config.get('auth0')
if (!auth0Config || !auth0Config.domain || !auth0Config.managementClientId || !auth0Config.managementClientSecret) {
throw Error('Auth0 Management Client credentials are not present')
}
this._config = config
this._management = new ManagementClient({
domain: auth0Config.domain,
clientId: auth0Config.managementClientId,
clientSecret: auth0Config.managementClientSecret
})
}
/**
* Ensures that we have a client (application) on Auth0 whose configuration matches the desired one. If a client ID is passed, the method checks if the client exists and updates it; otherwise, it will create a new client.
*
* @param {string} [clientId] - Auth0 client (application) ID
* @returns {string} Client ID of the application (either new or updated)
*/
async syncClient(clientId) {
// Check if we already have a client and it exists
if (clientId) {
// Check if it exists; if it does, update the data
let data
try {
data = await this.getClient(clientId)
}
catch (err) {
// If the exception is because the client doesn't exist, all good; otherwise, re-throw it
if (err.toString().match(/Not Found/i)) {
data = null
}
else if (err.name && err.name == 'access_denied') {
throw Error('Invalid Auth0 credentials')
}
else {
throw err
}
}
// If we have an existing client, update it
if (data) {
try {
await this.updateClient(clientId)
}
catch (err) {
if (err.name && err.name == 'access_denied') {
throw Error('Invalid Auth0 credentials')
}
else {
throw err
}
}
}
else {
clientId = undefined
}
}
// If client doesn't exist, create it
if (!clientId) {
try {
clientId = await this.createClient()
}
catch (err) {
if (err.name && err.name == 'access_denied') {
throw Error('Invalid Auth0 credentials')
}
else {
throw err
}
}
}
return clientId
}
/**
* Updates a client (application) on Auth0 so the configuration matches the desired one.
*
* @param {string} clientId - Auth0 client (application) ID
* @returns {string} Client ID of the updated application
*/
async updateClient(clientId) {
const params = {
client_id: clientId
}
const result = await this._management.clients.update(params, this._clientConfiguration())
if (result) {
return result.client_id
}
}
/**
* Create the client (application) on Auth0.
*
* @returns {string} Client ID of the new application
*/
async createClient() {
// Create the client
const result = await this._management.clients.create(this._clientConfiguration())
if (result) {
return result.client_id
}
}
/**
* Retrieve a client (application) from Auth0.
*
* @param {string} clientId - Client ID
*/
async getClient(clientId) {
const data = await this._management.clients.get({client_id: clientId})
if (!data || data.client_id != clientId) {
return null
}
return data
}
/**
* Ensures that the rules Hereditas needs are present in Auth0, and re-creates them so they're on the last version of the configuration and code.
*
* @param {Array} [ruleIds] - List of rules already created by Hereditas (if any)
* @returns {Array} New list of rules managed by Hereditas
*/
async syncRules(ruleIds) {
// First, check if the rules still exist
if (ruleIds && ruleIds.length) {
const rules = await this.listRules()
if (rules && Array.isArray(rules) && rules.length) {
// Delete all rules from the array that still exist
const promises = []
for (let i = 0; i < rules.length; i++) {
const el = rules[i]
if (!el || !el.id) {
continue
}
if (ruleIds.indexOf(el.id) != -1) {
promises.push(this._management.rules.delete({id: el.id}))
}
}
// Await all requests in parallel
await Promise.all(promises)
}
}
// Lastly, re-create all rules and return the new IDs
return this.createRules()
}
/**
* List all rules
*
* @returns {Array} List of rules
* @async
*/
listRules() {
return this._management.rules.getAll()
}
/**
* Create all rules required by Hereditas.
*
* @returns {Array} Array with the ID of the rules, in order
* @async
*/
async createRules() {
// Read all scripts
const readFilePromise = util.promisify(fs.readFile)
const scripts = await Promise.all([
readFilePromise(path.resolve(__dirname, '../../auth0/01-whitelist.js'), 'utf8'),
readFilePromise(path.resolve(__dirname, '../../auth0/02-notify.js'), 'utf8'),
readFilePromise(path.resolve(__dirname, '../../auth0/03-wait-logic.js'), 'utf8')
])
const names = [
'Hereditas 01 - Whitelist email addresses',
'Hereditas 02 - Notify',
'Hereditas 03 - Wait logic'
]
// Replacer function in scripts
const users = this._config.get('users') || []
const replacer = (script) => {
const vars = {
'/*%ALL_USERS%*/': JSON.stringify(users.map((el) => el.email)),
'/*%OWNERS%*/': JSON.stringify(users.filter((el) => el.role == 'owner').map((el) => el.email))
}
return script.replace(/\/\*%([A-Za-z0-9_]+)%\*\//, (token) => {
return vars[token]
})
}
// Create all rules, in order
const promises = []
for (let i = 0; i < 3; i++) {
promises.push(this._management.rules.create({
enabled: true,
stage: 'login_success',
order: i + 1,
name: names[i],
script: replacer(scripts[i])
}))
}
const results = await Promise.all(promises)
// Return the IDs of the rules
return results.map((el) => el.id)
}
/**
* List all rules configurations (only the keys, not values)
*
* @returns {Array} Array with all the rules configurations
* @async
*/
listRulesConfigs() {
return this._management.rulesConfigs.getAll()
}
/**
* Updates all rules configurations. This creates new configurations, and overwrites existing ones.
*
* @async
*/
async updateRulesConfigs() {
const rulesConfigs = {
APP_TOKEN: this._config.get('appToken'),
AUTH0_CLIENT_ID: this._config.get('auth0.managementClientId'),
AUTH0_CLIENT_SECRET: this._config.get('auth0.managementClientSecret'),
WEBHOOK_URL: this._config.get('webhookUrl') || '0'
}
// Create all rules configurations
const promises = []
for (const key in rulesConfigs) {
const value = rulesConfigs[key]
promises.push(this._management.rulesConfigs.set({key}, {value}))
}
await Promise.all(promises)
}
/**
* Returns the configuration object for a client (application) on Auth0.
*
* @returns {Object} Configuration object for the client (application) on Auth0
*/
_clientConfiguration() {
return {
name: 'Hereditas',
is_first_party: true,
oidc_conformant: true,
cross_origin_auth: false,
description: 'This application is managed by the Hereditas CLI. For information, see https://hereditas.app',
logo_uri: '',
sso: false,
callbacks: this._config.get('urls'),
allowed_logout_urls: [],
allowed_clients: [],
client_metadata: {
requestTime: '0',
waitTime: this._config.get('waitTime') + '', // Cast as string
hereditas: '1'
},
allowed_origins: [],
jwt_configuration: {
alg: 'RS256',
lifetime_in_seconds: 1800
},
token_endpoint_auth_method: 'none',
app_type: 'spa',
grant_types: [
'implicit'
]
}
}
}
module.exports = Auth0Management
================================================
FILE: cli/lib/Builder.js
================================================
'use strict'
const fs = require('fs')
const crypto = require('crypto')
const {Readable} = require('stream')
const util = require('util')
const Content = require('./Content')
const {CleanDirectory} = require('./Utils')
const path = require('path')
const kw = require('./aes-kw')
const argon2 = require('argon2-browser')
// Webpack
const webpack = util.promisify(require('webpack'))
const webpackConfig = require('../../app/webpack.config')
// Promisified fs.readdir, fs.stat and fs.unlink
const readdirPromise = util.promisify(fs.readdir)
const statPromise = util.promisify(fs.stat)
// Promisified crypto.pbkdf2 and crypto.randomBytes
const pbkdf2Promise = util.promisify(crypto.pbkdf2)
const randomBytesPromise = util.promisify(crypto.randomBytes)
/**
* Object containing properties for a file in the content directory
*
* @typedef {Object} HereditasContentFile
* @property {string} path - Path of the file (relative to the contentDir)
* @property {number} size - File size in bytes
* @property {string} dist - Random filename used in the dist folder
* @property {string} tag - Authentication tag for AES-GCM
* @property {string} processed - If the file has been pre-processed, this explains how (e.g. "markdown"); it's undefined otherwise
* @property {"text"|"image"|"attach"} display - Configures how the file should be displayed
*/
/**
* Builds a project
*/
class Builder {
/**
* Initializes the object
* @param {string} passphrase - User passphrase
* @param {Config} config - Config object
*/
constructor(passphrase, config) {
// Store config in the object
this._config = config
this._passphrase = passphrase
// Output
this.keySalt = null
this.indexTag = null
this.hasErrors = false
}
/**
* Performs a full build
*
* @async
*/
async build() {
// Step 1: clean dist directory
await CleanDirectory(this._config.get('distDir'))
// Step 2: get the list of files
let content = await this._scanContent()
// Step 3: generate a salt for deriving the encryption key
// This needs to be of 64 bytes, which is the length of a SHA-512 hash
this.keySalt = await randomBytesPromise(64)
// Step 4: derive the master key
const masterKey = await this._deriveKey(this._passphrase + this._config.get('appToken'), this.keySalt)
// Step 5: encrypt all files
content = await this._encryptContent(masterKey, content)
// Step 6: write an (encrypted) index file
this.indexTag = await this._createIndex(masterKey, content)
// Step 7: build the app with webpack
const appParams = {
distDir: this._config.get('distDir'),
authIssuer: 'https://' + this._config.get('auth0.domain'),
authClientId: this._config.get('auth0.hereditasClientId'),
idTokenNamespace: 'https://hereditas.app',
indexTag: this.indexTag,
keySalt: this.keySalt,
kdf: this._config.get('kdf'),
pbkdf2Iterations: this._config.get('pbkdf2.iterations'),
argon2Iterations: this._config.get('argon2.iterations'),
argon2Memory: this._config.get('argon2.memory')
}
const webpackStats = await webpack(webpackConfig(appParams))
// Check if webpack compilation had errors
if (webpackStats.hasErrors()) {
const errors = webpackStats.toJson().errors
// eslint-disable-next-line no-console
console.error('\x1b[31m\x1b[1m' + 'WEBPACK ERRORS' + '\x1b[0m\n')
for (const i in errors) {
// eslint-disable-next-line no-console
console.error('\x1b[31m' + errors[i] + '\x1b[0m\n')
}
this.hasErrors = true
}
if (webpackStats.hasWarnings()) {
const warnings = webpackStats.toJson().warnings
// eslint-disable-next-line no-console
console.warn('\x1b[33m\x1b[1m' + 'WEBPACK WARNINGS' + '\x1b[0m\n')
for (const i in warnings) {
// eslint-disable-next-line no-console
console.warn('\x1b[33m' + warnings[i] + '\x1b[0m\n')
}
}
}
/**
* Derives a 256 bit key from the passphrase and the salt, using the preferred key derivation function.
* The key can be used directly for symmetric encryption.
*
* @param {string} passphrase - Passphrase for the key
* @param {Buffer} salt - Salt for the key
* @returns {Promise} Promise that resolves to the buffer with the key
* @async
*/
_deriveKey(passphrase, salt) {
const kdf = this._config.get('kdf')
if (kdf == 'pbkdf2') {
// Using SHA-512, the result is a 512 bit key, so truncate it to 256 bit (32 bytes)
return pbkdf2Promise(
passphrase,
salt,
this._config.get('pbkdf2.iterations'),
32,
'sha512'
)
}
else if (kdf == 'argon2') {
return Promise.resolve()
.then(() => argon2.hash({
pass: passphrase,
salt: salt,
type: argon2.ArgonType.Argon2id,
time: this._config.get('argon2.iterations'),
mem: this._config.get('argon2.memory'),
hashLen: 32,
parallelism: 1
}))
.then((res) => {
return Buffer.from(res.hash)
})
}
else {
throw Error('Invalid key derivation function requested')
}
}
/**
* Creates an index file and encrypts it on disk.
*
* @param {Buffer} masterKey - Master encryption key
* @param {HereditasContentFile[]} content - List of content
* @returns {Buffer} Authentication tag
* @async
*/
async _createIndex(masterKey, content) {
// Creat the index file, and convert it to a Readable Stream
const indexData = JSON.stringify(content)
const inStream = new Readable()
inStream._read = () => {} // _read is required, but it's a no-op
inStream.push(indexData, 'utf8')
inStream.push(null) // End
// Output stream
const outStream = fs.createWriteStream(path.join(this._config.get('distDir'), '_index'))
// Encrypt the index and write it, returning the tag
return this._encryptStream(masterKey, inStream, outStream)
}
/**
* Encrypts all the content
* @param {Buffer} masterKey - Master encryption key
* @param {HereditasContentFile[]} content - List of content
* @returns {HereditasContentFile[]} - List of content with the dist and tag properties set
* @async
*/
async _encryptContent(masterKey, content) {
// Clone the content object
const result = JSON.parse(JSON.stringify(content))
// Iterate through the content and encrypt each file
for (const i in result) {
// Generate the file name for the output file (a random hex string)
const dist = (await randomBytesPromise(12)).toString('hex')
// Create the Readable stream to the input, and Writable stream to the output
const outStream = fs.createWriteStream(path.join(this._config.get('distDir'), dist))
// Pre-process the file
const content = new Content(result[i], this._config)
await content.process()
result[i] = content.el
// Encrypt the stream and get the tag
const tagBuf = await this._encryptStream(masterKey, content.inStream, outStream)
const tag = tagBuf.toString('base64')
// Add the dist and tag properties to the result object
result[i].dist = dist
result[i].tag = tag
}
return result
}
/**
* Encrypts a stream using aes-256-gcm
*
* @param {Buffer} masterKey - Master key; must be 256 bit long
* @param {Stream} inStream - Readable stream with the data to encrypt
* @param {Stream} outStream - Writable stream to pipe the data to
* @returns {Buffer} Authentication tag
* @async
*/
async _encryptStream(masterKey, inStream, outStream) {
// Generate a key for this specific file
const fileKey = await randomBytesPromise(32)
// Generate an IV
const fileIV = await randomBytesPromise(12)
// Wrap the file's key with the master key, using AES-KW (RFC-3394)
const wrappedKey = kw.encrypt(masterKey, fileKey)
return new Promise((resolve, reject) => {
// Write the wrapped key and IV to the outStream, at the beginning
outStream.write(wrappedKey)
outStream.write(fileIV)
// Create the Cipher, which can be used as a stream transform too
const cipher = crypto.createCipheriv('aes-256-gcm', fileKey, fileIV)
// When the encryption is done, get the authentication tag
cipher.on('end', () => {
resolve(cipher.getAuthTag())
})
// In case of errors, throw
inStream.on('error', reject)
outStream.on('error', reject)
// Pipe the input stream through the cipher and then to the output stream
inStream.pipe(cipher).pipe(outStream)
})
}
/**
* Recursively scans the content directory, listing files
* @returns {HereditasContentFile[]} List of files
* @async
*/
async _scanContent() {
// Will contain the final list
const result = []
// Recursive function that scans folders
const scanFolder = async (folder) => {
folder = folder || ''
// Scan the list of files and folders, recursively
const list = await readdirPromise(path.join(this._config.get('contentDir'), folder))
for (const e in list) {
const el = folder + list[e]
// Check if we need to include this path or ignore it
if (!includePath(el)) {
continue
}
// Check if it's a directory
const stat = await statPromise(path.join(this._config.get('contentDir'), el))
if (!stat) {
continue
}
// If it's a directory, scan it recursively
if (stat.isDirectory()) {
await scanFolder(el + path.sep)
}
else {
// Add the file to the list
result.push({
path: el,
size: stat.size
})
}
}
}
// Get the list
await scanFolder()
return result
}
}
// Returns true if a path should be included in the box
// This ignores files such as operating system's metadata
function includePath(str) {
const base = path.basename(str)
if (
// Linux
base.endsWith('~') ||
base == '.directory' ||
// macOS
base == '.DS_Store' ||
base == '.AppleDouble' ||
base == '.LSOverride' ||
base.startsWith('._') ||
// Windows
base == 'Thumbs.db' ||
base == 'Thumbs.db:encryptable' ||
base == 'desktop.ini' ||
base == 'Desktop.ini'
) {
return false
}
return true
}
module.exports = Builder
================================================
FILE: cli/lib/Config.js
================================================
'use strict'
const fs = require('fs')
const util = require('util')
const defaultsDeep = require('lodash.defaultsdeep')
const cloneDeep = require('lodash.clonedeep')
const SMHelper = require('smhelper')
const ConfigVersion = 20190222
/**
* Authorized users
*
* @typedef {object} HereditasUser
* @property {string} email - Email address
* @property {"user"|"owner"} role - Role: "user" or "owner"
*/
/**
* Configuration dictionary for Hereditas
*
* @typedef {object} HereditasConfig
* @property {number} version - Version of the configuration object
* @property {string} contentDir - Folder containing the source content
* @property {string} distDir - Folder where to place the compiled project
* @property {boolean} processMarkdown - If true, enable processing of Markdown files into HTML
* @property {object} auth0 - Auth0 configuration
* @property {string} auth0.domain - Auth0 domain/tenant (e.g. "myhereditas.auth0.com")
* @property {string} auth0.hereditasClientId - Auth0 app client ID for Hereditas
* @property {string} auth0.managementClientId - Client ID for the Auth0 Management app
* @property {string} auth0.managementClientSecret - Client Secret for the Auth0 Management app
* @property {Array} rules - ID of the Auth0 rules created by the Hereditas CLI
* @property {"pbkdf2"|"argon2"} kdf - Key derivation function to use: pbkdf2 or argon2 (default)
* @property {object} pbkdf2 - Configuration for pbkdf2
* @property {string} pbkdf2.iterations - Number of iterations
* @property {string} webhookUrl - URL of the webhook to trigger when a new user logs into Hereditas.
* @property {Array} users - List of users
* @property {string} appToken - Application token; when combined with the user passphrase, this allows deriving the encryption key
* @property {number} waitTime - The amount of time, in seconds, to wait before Auth0 can return to users the app token
* @property {Array} urls - list of URLs where your app will be deployed to, e.g. `https://hereditas.example.com`, or `https://myname.blob.core.windows.net/hereditas`, etc; this is used for OAuth redirects.
*/
/**
* Helper class for managing Hereditas configuration
*/
class Config {
/**
* Initializes the object.
*
* @param {string} [filename="hereditas.json"] - Name of the file on disk
*/
constructor(filename) {
if (!filename) {
filename = 'hereditas.json'
}
this._filename = filename
// userConfig is the data read from the config file. config is that, plus defaults
this._userConfig = null
this._config = {}
}
/**
* Create a new userConfig object.
*
* Note that this doesn't save changes on disk, you must manually call `save()`.
*
* @param {HereditasConfig} initConfig - Initial configuration values
*/
create(initConfig) {
this._userConfig = {
version: ConfigVersion
}
defaultsDeep(this._userConfig, initConfig)
// Update the config in memory
this._config = {}
this._defaults()
}
/**
* Reads and parses a config file, validating it.
*
* @param {string} [filename="hereditas.json"] - Name of the config file to read; default is "hereditas.json"
* @throws Throws an error if the config file doesn't exist or is not a valid Hereditas config
*/
async load() {
// Read the file
const configFile = await util.promisify(fs.readFile)(this._filename, 'utf8')
if (!configFile) {
throw Error('Cannot read config file')
}
// Parse JSON and ensure it's a valid format
this._userConfig = JSON.parse(configFile)
if (!this._validate()) {
throw Error('Invalid config file')
}
// Apply defaults
this._defaults()
}
/**
* Returns value for key from configuration
*
* @param {string} key - Key of the object, in "dot notation"
* @returns {*} Value of the configuration key requested (cloned)
*/
get(key) {
let val
// If key contains a dot, we are requesting a nested object
if (key.indexOf('.') != -1) {
val = SMHelper.getDescendantProperty(this._config, key)
}
else {
val = this._config[key]
}
// Returns a clone of the object so it can't be modified
return cloneDeep(val)
}
/**
* Returns all config values (cloned).
*
* @returns {HereditasConfig} All configuration data
*/
all() {
return cloneDeep(this._config)
}
/**
* Updates the value of a user config.
*
* Note: this does NOT save the changes on disk; you must invoke `save()` for that.
*
* @param {string} key - Name of the key to update, using the "dot notation"
* @param {*} value - New value
*/
set(key, value) {
// Update the value and validate the config
SMHelper.updatePropertyInObject(this._userConfig, key, value)
if (!this._validate()) {
throw Error('Invalid config data')
}
// Update the config in memory
this._config = {}
this._defaults()
}
/**
* Save changes to user configuration to disk.
*
* @returns {Promise} Returns a promise that resolves when the changes have been committed to disk.
* @async
*/
save() {
return util.promisify(fs.writeFile)(this._filename, JSON.stringify(this._userConfig, null, 2))
}
/**
* Validates a config object, ensuring that all required keys are present.
*
* @returns {boolean} Returns true on valid configuration objects
* @throws Throws an Error if the config file isn't valid
*/
_validate() {
if (!this._userConfig || typeof this._userConfig != 'object' || !Object.keys(this._userConfig).length) {
throw Error('Invalid config file')
}
if (!this._userConfig.version) {
throw Error('Config file is missing required key version')
}
if (!this._userConfig.distDir) {
throw Error('Config file is missing required key distDir')
}
if (!this._userConfig.contentDir) {
throw Error('Config file is missing required key contentDir')
}
if (!this._userConfig.appToken) {
throw Error('Config file is missing required key appToken')
}
if (!this._userConfig.auth0 || typeof this._userConfig.auth0 != 'object' || !Object.keys(this._userConfig.auth0).length) {
throw Error('Config file is missing required key auth0')
}
if (!this._userConfig.auth0.domain) {
throw Error('Config file is missing required key auth0.domain')
}
if (!this._userConfig.urls || !Array.isArray(this._userConfig.urls) || !this._userConfig.urls.length) {
throw Error('Config file is missing required key urls')
}
return true
}
/**
* Applies default parameters to the userConfig object, and stores that into the config object
*/
_defaults() {
defaultsDeep(this._config, this._userConfig, {
processMarkdown: true,
kdf: 'argon2',
pbkdf2: {
iterations: 100000
},
argon2: {
iterations: 2,
memory: 64 * 1024
}
})
}
}
module.exports = Config
================================================
FILE: cli/lib/Content.js
================================================
'use strict'
const fs = require('fs')
const {Readable} = require('stream')
const util = require('util')
const path = require('path')
// Marked.js
const marked = util.promisify(require('marked'))
// Promisified fs.readFile, fs.readdir, fs.stat and fs.unlink
const readFilePromise = util.promisify(fs.readFile)
// List of file extensions of images
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']
/**
* Processes content
*/
class Content {
/**
* Constructor
*
* @param {HereditasContentFile} el - Content to process
* @param {Config} config - Config object
*/
constructor(el, config) {
this._config = config
this._el = el
this._inStream = null
}
/**
* Object with the content information, potentially modified
*
* @returns {HereditasContentFile} Content information object
*/
get el() {
return this._el
}
/**
* Readable stream to the (optionally, pre-processed) content
*
* @returns {ReadableStream} Stream with content data
*/
get inStream() {
return this._inStream
}
/**
* Pre-processes the content in any way necessary, e.g. converting Markdown into HTML.
*/
async process() {
if (this._el.path.match(/\.txt$/i)) {
await this._processText()
}
else if (this._el.path.match(/\.(md|markdown)$/i)) {
await this._processMarkdown()
}
else {
await this._processBinary()
}
}
/**
* Processes images and other binary files
*/
async _processBinary() {
// Just get a stream to the file on disk
this._inStream = fs.createReadStream(path.join(this._config.get('contentDir'), this._el.path))
// Set the display as "image" for images, and "attach" for anything else
const extension = this._el.path.split('.')
.pop()
.toLowerCase()
this._el.display = (imageExtensions.indexOf(extension) < 0) ?
'attach' :
'image'
}
/**
* Processes simple Text files
*/
async _processText() {
// Get a stream to the file and display it as text
this._inStream = fs.createReadStream(path.join(this._config.get('contentDir'), this._el.path))
this._el.display = 'text'
}
/**
* Processes Markdown files, converting them to HTML
*/
async _processMarkdown() {
// Check if we process Markdown into HTML
if (this._config.get('processMarkdown')) {
const markdown = await readFilePromise(path.join(this._config.get('contentDir'), this._el.path), 'utf8')
const html = await marked(markdown)
// Push the data into a stream
this._inStream = new Readable()
this._inStream._read = () => {} // _read is required, but it's a no-op
this._inStream.push(html, 'utf8')
this._inStream.push(null) // End
// Mark the file as pre-processed
this._el.processed = 'markdown'
this._el.display = 'html'
// TODO: Handle different encodings
}
else {
// If not processing them, treat Markdown files as simple text
await this._processText()
}
}
}
module.exports = Content
================================================
FILE: cli/lib/Crypto.js
================================================
'use strict'
const crypto = require('crypto')
const util = require('util')
/**
* Generates a token with `length` random bytes, and returns it as a base64-encoded string.
*
* @param {number} length - Number of bytes to generate (before converting to base64)
* @returns {string} Token represented as base64-encoded string
*/
async function GenerateToken(length) {
if (!length || length < 0) {
length = 20
}
const bytes = await util.promisify(crypto.randomBytes)(length)
return bytes.toString('base64')
}
module.exports = {
GenerateToken
}
================================================
FILE: cli/lib/Utils.js
================================================
const fs = require('fs')
const util = require('util')
const path = require('path')
const readdirPromise = util.promisify(fs.readdir)
const unlinkPromise = util.promisify(fs.unlink)
const rmdirPromise = util.promisify(fs.rmdir)
/**
* Deletes all files in a directory, without removing the directory itself.
*
* @param {string} directory - Directory to clean
* @async
*/
async function CleanDirectory(directory) {
const files = await readdirPromise(directory)
return Promise.all(files.map(
(file) => {
const target = path.join(directory, file)
const stat = fs.lstatSync(target)
if (stat.isDirectory()) {
return CleanDirectory(target)
.then(() => rmdirPromise(target))
}
return unlinkPromise(target)
}
))
}
module.exports = {
CleanDirectory
}
================================================
FILE: cli/lib/aes-kw.js
================================================
/**
* This module is based on https://github.com/calvinmetcalf/aes-kw
*
* Copyright (C) Calvin Metcalf. Released under MIT license.
*/
const crypto = require('crypto')
const xor = require('buffer-xor/inplace')
const bufferEq = require('buffer-equal-constant-time')
const IV = Buffer.from('A6A6A6A6A6A6A6A6', 'hex')
const EMPTY_BUF = Buffer.alloc(0)
function Encrypter(key, decipher) {
if (decipher) {
this.cipher = crypto.createDecipheriv(getCipherName(key), key, EMPTY_BUF)
}
else {
this.cipher = crypto.createCipheriv(getCipherName(key), key, EMPTY_BUF)
}
this.cipher.setAutoPadding(false)
}
Encrypter.prototype.encrypt = function(iv, buf) {
if (iv.length !== 8) {
throw new Error('invalid iv length')
}
if (buf.length !== 8) {
throw new Error('invalid data length')
}
this.cipher.update(iv)
return this.cipher.update(buf)
}
Encrypter.prototype.done = function() {
this.cipher.final()
}
function getCipherName(key) {
switch (key.length) {
case 16: return 'aes-128-ecb'
case 24: return 'aes-192-ecb'
case 32: return 'aes-256-ecb'
}
}
function msb(b) {
return b.slice(0, 8)
}
function lsb(b) {
return b.slice(-8)
}
exports.encrypt = encrypt
function encrypt(key, plaintext) {
if (plaintext.length % 8) {
throw new Error('must be 64 bit increment')
}
const enc = new Encrypter(key)
let j = -1
let i, b
const t = Buffer.alloc(8)
let a = IV
const n = plaintext.length / 8
const r = createR(plaintext)
while (++j <= 5) {
i = -1
while (++i < n) {
b = enc.encrypt(a, r[i])
t.writeUInt32BE(0, 0)
t.writeUInt32BE((n * j) + i + 1, 4)
a = xor(msb(b), t)
r[i] = lsb(b)
}
}
enc.done()
return Buffer.concat([a].concat(r))
}
exports.decrypt = decrypt
function decrypt(key, ciphertext) {
if (ciphertext.length % 8) {
throw new Error('must be 64 bit increment')
}
const enc = new Encrypter(key, true)
let j = 6
let i, b
const t = Buffer.alloc(8)
const n = ciphertext.length / 8
const r = createR(ciphertext)
let a = r[0]
while (--j >= 0) {
i = n
while (--i) {
t.writeUInt32BE(0, 0)
t.writeUInt32BE(((n - 1)* j) + i, 4)
a = xor(a, t)
b = enc.encrypt(a, r[i])
a = msb(b)
r[i] = lsb(b)
}
}
enc.done()
if (!bufferEq(a, IV)) {
throw new Error('unable to decrypt')
}
return Buffer.concat(r.slice(1))
}
function createR(buf) {
const n = buf.length / 8
const out = new Array(n)
let i = -1
while (++i < n) {
out[i] = buf.slice(i * 8, (i + 1) * 8)
}
return out
}
================================================
FILE: docs-source/.gitignore
================================================
# Generated files
/content/cli/*.md
!/content/cli/__template.md
/content/menu/*.md
!/content/menu/__template.md
/dist
# Created by https://www.gitignore.io/api/hugo
# Edit at https://www.gitignore.io/?templates=hugo
### Hugo ###
# gitginore template for Hugo projects
# website: https://gohugo.io
# generated files by hugo
/public/
/resources/_gen/
# executable may be added to repository
hugo.exe
hugo.darwin
hugo.linux
# End of https://www.gitignore.io/api/hugo
================================================
FILE: docs-source/config.yaml
================================================
baseURL: "https://hereditas.app/"
languageCode: en-us
title: Hereditas
# Ignore files
ignoreFiles:
- "\\.sh$"
- "Makefile"
- "Dockerfile"
- "__template.md"
# Enable all URLs to be relative, and make them end with ".html"
relativeURLs: true
canonifyURLs: false
uglyurls: true
# Book Theme is intended for documentation use, therefore it doesn't render taxonomy.
# You can hide related warning with config below
disableKinds:
- taxonomy
- taxonomyTerm
- section
# Goldmark
markup:
goldmark:
renderer:
unsafe: true
# Syntax highlighting
pygmentsCodeFences: true
pygmentsStyle: "tango"
# Google analytics
#googleAnalytics: UA-72379106-2
# Privacy
privacy:
googleAnalytics:
anonymizeIP: true
youtube:
privacyEnhanced: true
# Theme
theme: book
# Theme params
params:
# (Optional, default true) Show or hide table of contents globally
# You can also specify this parameter per page in front matter
BookShowToC: true
# (Optional, default none) Set leaf bundle to render as side menu
# When not specified file structure and weights will be used
BookMenuBundle: /menu
# (Optional, default docs) Specify section of content to render as menu
# You can also set value to "*" to render all sections to menu
BookSection: docs
# This value is duplicate of $link-color for making active link highlight in menu bundle mode
# BookMenuBundleActiveLinkColor: \#004ed0
# Include JS scripts in pages. Disabled by default.
# - Keep side menu on same scroll position during navigation
BookEnableJS: false
# Set source repository location.
# Used for 'Last Modified' and 'Edit this page' links.
#BookRepo: https://github.com/ItalyPaleAle/hereditas/docs
# Enable "Edit this page" links for 'doc' page type.
# Disabled by default. Uncomment to enable. Requires 'BookRepo' param.
# Path must point to 'content' directory of repo.
#BookEditPath: edit/master/exampleSite/content
# Plausible Analytics
PlausibleAnalytics:
Domain: hereditas.app
================================================
FILE: docs-source/content/_index.md
================================================
---
title: What is Hereditas
type: docs
---

# What is Hereditas
**What happens to your digital life after you're gone?**
Hereditas, which means *inheritance* in Latin, is a static website generator that builds **fully-trustless digital legacy boxes**, where you can store information for your relatives to access in case of your sudden death or disappearance.
For example, you could use this to pass information such as passwords, cryptographic keys, cryptocurrency wallets, sensitive documents, etc.
{{< youtube lZEKgB5dzQ4 >}}
> Note: the video above was recorded with Hereditas 0.1. The design of the interface has been improved and made nicer in 0.2.
## Why we built Hereditas
Check out the announcement [**blog post**](https://withblue.ink/2019/03/18/what-happens-to-your-digital-life-after-youre-gone-introducing-hereditas.html?utm_source=web&utm_campaign=hereditas-docs) to understand more about why we built Hereditas and why you need it too.
## Design
We've designed Hereditas with three principles in mind.
### Fully trustless – really
With Hereditas, you don't need to trust any person or provider. **No other person or company has standing access to your data.**
As the owner of an Hereditas box, you can nominate some authorized users by whitelisting their email address and giving them a *user passphrase*.
To prevent authorized users from having standing access to your data, however, once they log into your Hereditas box for the first time, they need to wait for a few hours or days before they can unlock the box. This gives you, the owner of the box, enough time to stop the timer, by simply logging into the same Hereditas box.
For example, if you set the waiting time to 24 hours (the default), when a relative of yours tries to log in the timer starts and Hereditas sends you a notification right away. If you've not disappeared, you can log into the same Hereditas box within 24 hours and stop the timer. Without any action from you, after the delay has passed all your relatives would be able to unlock your Hereditas box by logging in again and typing the *user passphrase*.
Hereditas generates digital legacy boxes that are encrypted bundles within static HTML5 applications. The encryption key is split between what you give your users and what's stored inside the authorization provider, so no company or provider has standing access to your data.
### Simple for your loved ones
We designed Hereditas so it's simple to use for your loved ones, when they need to access your digital legacy box, even if they are not tech wizards. **A web browser is all they need.**
As the owner of the Hereditas box, you will provide them with the URL where they can find your box, and the *user passphrase* they need to use to unlock it. You will also whitelist their email address so they can log in with their existing accounts (e.g. Google, Facebook, Microsoft…) – no need to create new accounts for them and have new passwords around.
### No costly and/or time-consuming maintenance
You don't want to rely on a solution that you'll have to keep paying and/or patching for the rest of your life (and in this case, we mean that literally).
**Hereditas outputs a static HTML5 app that you can host anywhere you'd like**, for free or almost.
## Open source
We made Hereditas fully open source so you can study how the app works down to every detail. We wrote the app in JavaScript, and we use Node.js for the CLI and HTML5 for the static web app. **The source code is available on GitHub at [ItalyPaleAle/hereditas](https://github.com/ItalyPaleAle/hereditas)** under GNU General Public License (GPL) version 3.0 (see [LICENSE](https://github.com/ItalyPaleAle/hereditas/tree/master/LICENSE.md)).
We happily accept contributions! Feel free to submit a Pull Request to fix bugs or add new features. Equally important, you can contribute by improving this [documentation](https://github.com/ItalyPaleAle/hereditas/tree/master/docs-source) you're reading.
If you believe you've found a security issue that could impact other people, please [report it confidentially](https://www.npmjs.com/advisories/report?package=hereditas).
## Get started
Ready? Get started with Hereditas now!
}}">Quickstart Video
Or:
}}">Get started documentation
================================================
FILE: docs-source/content/advanced/auth0-manual-configuration.md
================================================
---
title: Auth0 manual configuration
type: docs
---
# Auth0 manual configuration
Hereditas uses Auth0 to authenticate users and to provide the *application token*, which is part of the string used to derive the encryption key.
This document explains the configuration that the Hereditas CLI performs when you execute the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command.
> **Important:** this page is primarily primarily meant as reference. We recommend letting the Hereditas CLI manage the Auth0 configuration with the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command rather than changing settings manually.
## Differences with the "API Access" application
In the [Auth0 setup]({{< relref "/guides/auth0-setup.md" >}}) article we guided you through the creation of an **API Access** app ("Machine to Machine") and how to get the credentials, which are used by the Hereditas CLI to configure Hereditas on Auth0, including setting up the rules, and also by the Hereditas rules on Auth0 to set timers.
This document focuses on the main "Hereditas" application on Auth0, which is what users will authenticate with.
## Hereditas application
On Auth0, create an application of type **Single Page Application**. You can name it any way you want, but `Hereditas` is probably a good name.
Once the app is created, take note of the **Domain** and the **Client ID**. We will not need the Client Secret.
### Application configuration
Ensure that the application is configured with:
- **Application type**: Should be "Single Page Application"
- **Allowed callback URLs**: List of URLs (one per line) where your box is deployed to
- **JWT Expiration**: Recommended to set it to a value that make sense for you, for example 1800 seconds (30 mins)
Under **Advanced Settings**, then **OAuth**:
- **JsonWebToken Signature Algorithm**: Should be "RS256"
- **OIDC Conformant**: Should be enabled
In the **Grant types** tab:
- **Grants**: choose only "Implicit"
### Application Metadata
The application needs to be configured with the following "Application Metadata" (called `client_metadata` in the Auth0 APIs):
- **`hereditas`**: this is required and must be set to `1`.
- **`requestTime`**: set this value to `0`. When users that are not owners sign in, the application rules automatically update this value with the current time (as UNIX timestamp).
- **`waitTime`**: the amount of time, in seconds, to wait before Auth0 can return to users (non-owners) the app token. Set this value to whatever makes sense for you; `86400` (1 day) is often a good amount of time.
## Rules
The `auth0` folder in the repository contains the rules that need to be configured in Auth0. Note that the order below is very important!
- **Hereditas 01 - Whitelist email addresses (`01-whitelist.js`)**: This rule configures which users are allowed to authenticate, by whitelisting their email address.
- **Hereditas 02 - Notify (`02-notify.js`)**: This rule sends a notification on all successful logins via a webhook.
- **Hereditas 03 - Wait logic (`03-wait-logic.js`)**: This rule implements the "wait logic". If a non-owner users signs in, the rule starts the timer (by setting the current timestamp in the `waitTime` application metadata). After the wait is over, this same rule adds the app token to the claim. If an owner signs in, the timer is reset (and the app token is added to the claim regardless).
The scripts above contain some tokens that need to be replaced with the list of email addresses of all users or just owners.
- **`/*%OWNERS%*/`** This token needs to be replaced with the JSON-encoded array of the email addresses of users who are owners.
- **`/*%ALL_USERS%*/`** This token needs to be replaced with the JSON-encoded array of the email addresses of all users.
For example:
````js
const whitelist = /*%ALL_USERS%*/;
const owners = /*%OWNERS%*/;
// Become
const whitelist = ["me@example.com", "someone@example.com"];
const owners = ["me@example.com"];
````
In the rules page, add the following settings. You will need some credentials from the "API Access" app, which is the "Machine to Machine" app created in the getting started guide.
- **`APP_TOKEN`**: the application token part of the encryption key.
- **`AUTH0_CLIENT_ID`**: Set this to the Client ID of the API Access app.
- **`AUTH0_CLIENT_SECRET`**: Set this to the Client Secret of the API Access app.
- **`WEBHOOK_URL`**: URL of the webhook invoked after a successful authentication (see the [Login notifications]({{< relref "/guides/login-notifications.md" >}})).
================================================
FILE: docs-source/content/advanced/building-self-contained-binaries.md
================================================
---
title: Building self-contained binaries
type: docs
---
# Building self-contained binaries
Starting with Hereditas 0.2, in addition to generating a set of files to be served via HTTP, you can also build a self-contained binary that can be distributed as an app without further dependencies. This binary launches a local web server, and it contains and serves all of your Hereditas box, including the (encrypted) files.
The [`hereditas pack`]({{< relref "/cli/pack.md" >}}) command, run inside your Hereditas working directory, automatically builds binaries for Windows (32-bit and 64-bit), macOS, and Linux (amd64, i386, arm64, armv7).
## Requirements
There are a few requirements before you can run the `hereditas pack` command (the CLI will check them for you too):
1. You need to have the Go compiler installed in your laptop, at least version 1.13.
1. You need to have packr2 installed in your laptop and available in your `PATH`; you can fetch it with `go get -u github.com/gobuffalo/packr/v2/packr2` or you can get a [pre-compiled binary](https://github.com/gobuffalo/packr/releases).
1. The URL `http://localhost:8080` must be allowed for your Hereditas box. You can do that with `hereditas url:add -u http://localhost:8080` (and then `hereditas auth0:sync`).
1. You must have already built your Hereditas box. That is, you must have run the `hereditas build` command.
## Build the binaries
Run the [`hereditas pack`]({{< relref "/cli/pack.md" >}}) command to automatically build the binaries; this can take a couple of minutes.
```sh
hereditas pack
```
The binaries will be placed in the `_bin` folder:
```sh
~/hereditas $ ls _bin
hereditas-box-linux-386
hereditas-box-linux-amd64
hereditas-box-linux-arm64
hereditas-box-linux-armv7
hereditas-box-macos
hereditas-box-win32.exe
hereditas-box-win64.exe
```
Pick the right binary for your system(s) and distribute them in any way you see fit.
> **macOS and Gatekeeper:** Hereditas does not sign the macOS binary, as that requires a developer certificate from Apple. The app you compile will run in your Mac without issues, but if you distribute it to other people, Gatekeeper might refuse to run it as it's unsigned. Read more about Gatekeeper in the [Apple support site](https://support.apple.com/en-us/HT202491).
## Running the self-contained app
Most users will be able to open your Hereditas box by double-clicking on the binary.
The app runs in the command line, and it should automatically open a terminal if you launch it through your operating system's shell.
The app launches a web server listening on `127.0.0.1` and on port `8080`. It will then automatically open the user's default web browser (if possible) with the URL `http://localhost:8080`.
================================================
FILE: docs-source/content/advanced/configuration-file.md
================================================
---
title: Configuration file
type: docs
---
# Configuration file
Each Hereditas working folder contains a JSON configuration file called `hereditas.json`. This file is automatically generated when running the [`hereditas init`]({{< relref "/cli/init.md" >}}) command, and it's modified by the app itself when you run certain commands.
## Contents
The structure of the document is similar to the following:
````json
{
"version": 20190222,
"contentDir": "content",
"distDir": "dist",
"appToken": "...",
"waitTime": 86400,
"users": [
{
"email": "me@example.com",
"role": "owner"
},
{
"email": "someone@example.com",
"role": "user"
}
],
"auth0": {
"domain": "myhereditas.auth0.com",
"managementClientId": "...",
"managementClientSecret": "...",
"hereditasClientId": "...",
"rules": [
'...',
'...',
'...'
]
},
"urls": [
"https://my.testhereditas.app",
"https://another.testhereditas.app"
],
"webhookUrl": "https://example.com/webhook/token/abc123XYZ",
"kdf": "argon2",
"argon2": {
"memory": 65536
},
"pbkdf2": {
"iterations": 100000
},
"processMarkdown": true
}
````
## Configuration options
### Hereditas working folder options
These options configure the way the Hereditas current working folder is set up.
- **`version`** (int): This represents the version of the configuration file. At present moment, this is `20190222`.
- **`contentDir`** (string): Name of the directory, inside the Hereditas working folder, containing the documents and files to include in the Hereditas box. The content of this folder is not encrypted, as the CLI will do that automatically. The default value is `content`. This value can be set during box initialization with the `--content` option for the [`hereditas init`]({{< relref "/cli/init.md" >}}) command.
- **`distDir`** (string): Name of the directory, inside the Hereditas working folder, where the CLI will put the generated web app and the encrypted content. The default value is `dist`. This value can be set during box initialization with the `--dist` option for the [`hereditas init`]({{< relref "/cli/init.md" >}}) command.
### Access control basics
These options are used to configure the way Hereditas protects access to your box.
- **`appToken`** (string): The *application token*, which together with the *user passphrased* is used to derive the encryption and decryption key. The application token is stored in the Hereditas configuration file and synced with Auth0. This is an important secret to protect, although by itself (without the *user passphrase*, which isn't stored anywhere) it isn't sufficient to encrypt or decrypt your data. This value is generated automatically when a new Hereditas project is initialized (using [`hereditas init`]({{< relref "/cli/init.md" >}})), and can be re-generated with [`hereditas regenerate-token`]({{< relref "/cli/regenerate-token.md" >}}). Note that changing this value will require re-building and re-deploying your box.
- **`waitTime`** (string): Amount of time users need to wait before they can unlock an Hereditas box, in seconds. The default value is `86400`, or 24 hours. This value can be also set with [`hereditas wait-time:set`]({{< relref "/cli/wait-time_set.md" >}})
- **`users`** (array of objects): Array of users that are whitelisted to use the app. Users can authenticate with Auth0 using any social account they support, e.g. Google, Facebook, Microsoft, etc; as long as the email address matches what's whitelisted in this configuration option. For more information, please refer to the documentation on [Managing users]({{< relref "/guides/managing-users.md" >}}). This value is an array of objects with the structure:
- **`users.$.email`** (string): email address of the user
- **`users.$.role`** (string): either `user` or `owner`
### Auth0 settings and credentials
The **`auth0`** object contains settings and credentials for communicating with the Auth0 APIs. For more information, please refer to the [Auth0 Setup]({{< relref "/guides/auth0-setup.md" >}}) article.
- **`auth0.domain`** (string): domain on Auth0, e.g. `myhereditas.auth0.com`. This is created when you sign up for Auth0, and it's globally unique. This is set with the `--auth0Domain` option for the [`hereditas init`]({{< relref "/cli/init.md" >}}) command.
- **`auth0.managementClientId`** (string): Client Id (ie. public key) for the "API Access" application on Auth0, a "Machine-to-Machine" application used by the Hereditas CLI and the rules to interact with the Auth0 APIs. This is set with the `--auth0ClientId` option for the `hereditas init` command.
- **`auth0.managementClientSecret`** (string): Client Secret (ie. private key) for the "API Access" application on Auth0. This is set with the `--auth0ClientSecret` option for the `hereditas init` command.
- **`auth0.hereditasClientId`** (string): Client Id (ie. public key) for the "Hereditas" application on Auth0. This value is generated automatically by the Auth0 CLI, when running the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command. In most cases, you should not edit this Client Id manually.
- **`auth0.rules`** (array of strings): These strings represent the Ids of the rules that the Hereditas CLI creates on Auth0. These are returned automatically by the Auth0 CLI, when running the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command. In most cases, you should not edit this array manually.
### Deployment and webhook URLs
The Hereditas configuration file stores two separate kinds of URLs:
- **`urls`** (array of strings): list of URLs where the Hereditas application is deployed to. These URLs are synced with Auth0, which uses them to whitelist callback URLs after users authenticate successfully. You should list every URL where your app might be reachable at. Note that the protocol needs to match too, so `http://example.com` and `https://example.com` are separate URLs. For more information, see the [Auth0 Setup]({{< relref "/guides/auth0-setup.md" >}}) and [Deploying the box]({{< relref "/guides/deploy-box.md" >}}) articles. You need to provide (at least) one URL with the `--url` flag when running [`hereditas init`]({{< relref "/cli/init.md" >}}). These URLs can also be changed with CLI commands, such as [`url:add`]({{< relref "/cli/url_add.md" >}}) and [`url:rm`]({{< relref "/cli/url_rm.md" >}}).
- **`webhookUrl`** (string): URL of the webhook that is invoked to notify owners of a successful authentication. Please refer to the [Login notifications]({{< relref "/guides/login-notifications.md" >}}) article for more information. This value can also be set with the [`webhook:set`]({{< relref "/cli/webhook_set.md" >}}) command.
### Advanced options
These options are set by default by Hereditas. You shouldn't change these options unless you have a good reason for that, and you're confident that you know what you're doing.
- **`kdf`** (string): Key derivation function to use. Supported values are `argon2` for Argon2id (default), and `pbkdf2` for PBKDF2.
- **`argon2`** (object): Parameters for deriving a key with Argon2.
- **`argon2.iterations`** (int): Iterations used by Argon2 (in Argon2id mode). Default value is 2.
- **`argon2.memory`** (int): Memory used by Argon2 (in Argon2id mode), in bytes. Default value is 65536 (64KB)
- **`pbkdf2`** (object): Parameters for deriving a key with PBKDF2.
- **`pbkdf2.iterations`** (int): Number of iterations to use for PBKDF2. Default value is 100000 (1E+05)
- **`processMarkdown`** (boolean): Switch to enable/disable the conversion of Markdown documents into HTML, when building the box. Default is true (enabled).
================================================
FILE: docs-source/content/advanced/index-file.md
================================================
---
title: Index file
type: docs
---
# Index file
Each Hereditas box contains an encrypted file named `_index`.
## Encryption details
The index file is encrypted, just like all other files, with AES-256-GCM. The encryption key is a unique, random sequence of 32 bytes (256 bits), which is wrapped with the master key and then stored (wrapped) at the beginning of the file. Additionally, the 12-byte IV is randomly generated too and stored in the file's data right after the wrapped key.
As a result of the usage of GCM, which is an authenticated cipher, the encryption step outputs an authentication tag too. The index file's authentication tag is stored inside the JavaScript file in cleartext and it's used to certify that the index file's content are authentic.
## Contents
In celartext, the index file is a JSON document listing all files inside the Hereditas box. For example:
````json
[
{
"path": "hello.md",
"dist": "043bd2a8986b5ed805737ab8",
"size": 248,
"display": "html",
"tag": "VczD/yHW3XtcH2nNyt9Q4w==",
"processed": "markdown"
},
{
"path": "photo.jpg",
"dist": "122c1a87b03db8793eb90d53",
"size": 10181034,
"display": "image",
"tag": "Zsh42WN+iy05M6CaXtlhPA=="
},
{
"path": "folder/passwords.pdf",
"dist": "715f14d0479b455ed481af9f",
"size": 60600,
"display": "attach",
"tag": "CjurYwY6KeeTrmJsKxdR1A=="
}
]
````
The JSON document is an array of objects each representing a file:
- `path`: The original path of the file in the content folder
- `dist`: Name of the encrypted file
- `size`: The size of the original file, in bytes
- `display`: Instructs the Hereditas web app on how to display the file. The generator determines this based on the file extension. Accepted values are:
- `html`: Display the content as HTML fragment inside the page (for converted Markdown files)
- `text`: Display the content as pre-formatted text, in a ` ` HTML block (for text files)
- `image`: Display the image inline (for images)
- `attach`: Prompts to download the file
- `tag`: The authentication tag for the encrypted file, as returned by the GCM cipher
- `processed`: Contains information on how the file was pre-processed. If not present, it means the file wasn't pre-processed. Possible values:
- `markdown`: The Markdown file was converted to HTML
================================================
FILE: docs-source/content/cli/__template.md
================================================
---
title: {{{commandName}}}
type: docs
---
# hereditas {{{commandName}}}
{{{shortDescription}}}
## Description
{{{longDescription}}}
{{#usage}}
## Example usage
````sh
{{{usage}}}
````
{{/usage}}
{{#hasFlags}}
## Flags
| Flag | Type | Required | Default Value | Description |
|---|---|---|---|---|
{{#flags}}
|{{{name}}} | {{{type}}} | {{required}} | {{{defaultValue}}} | {{description}} |
{{/flags}}
{{/hasFlags}}
================================================
FILE: docs-source/content/guides/auth0-setup.md
================================================
---
title: Auth0 setup
type: docs
---
# Auth0 setup
[Auth0](https://auth0.com/) is an authentication provider built to be flexible, safe and reliable. It offers a generous free tier that is more than enough for any user of Hereditas.
On Auth0, users can authenticate using their existing social logins, including Google, Facebook, Microsoft accounts; the list of supported providers is [fairly long](https://auth0.com/docs/identityproviders). This is very convenient because it lets your users sign in with existing credentials, so you don't need to create new accounts (and passwords) for them. It also offers increased security, as providers like Microsoft, Google, Facebook (and Auth0 itself) have a powerful infrastructure to prevent and detect malicious logins (often using AI trained on millions of authentications by their users every day), and they support Multi-Factor Authentication.
> **Why do we need Auth0?**
>
> Hereditas is a static website generator. In order to reduce the need for future maintenance and keeping operating costs down to zero (or almost), Hereditas outputs a static HTML5 web app with no server-side code at all. HTML5 apps nowadays are extremely powerful and we are able to do advanced cryptographic operations within a web browser.
>
> However, in order to implement the wait timer (ensuring that users need to wait a certain amount of time after their first login to unlock your box), we needed to store data in a centralized repository. Using Auth0 and splitting the encryption key between the *user passphrase* given to your users, and the *application token* stored inside the authentication provider, lets us precisely do that, while still maintaining the promise of a fully trustless platform. For more information, check out the [Security model]({{< relref "/introduction/security-model.md" >}}) article.
## Sign up for Auth0
On the [Auth0 website](https://auth0.com/), sign up and create a new (free) account.
After creating an account, you should automatically be redirected to the [Auth0 management portal](https://manage.auth0.com/), where you can create a new domain. Choose a name (must be universally unique) and a region, then continue the process until you've created your account and domain.

## Create the "API Access" application
Once you are inside the Auth0 management portal, click on the button to create a new application.
Throughout this documentation, we'll name this new application "API Access", even though you can choose whichever name you prefer. Choose type "Machine to Machine App", then create the app.

In the next step, you need to grant this application access to the Auth0 APIs. From the dropdown menu, select "Auth0 Management API". Then, select **all and only** the following scopes:
- read:clients
- update:clients
- create:clients
- read:rules
- update:rules
- delete:rules
- create:rules
- update:rules_configs

Lastly, from the Settings tab, take note of the following values, which we'll need to pass to the Hereditas CLI:
- Domain
- Client ID
- Client Secret

## Configure connections
One of the main benefits of using Auth0 is that it integrates with third-party identity providers such as Google, Microsoft, Facebook. This lets you skip creating new accounts for your users, so they can sign in with their existing credentials. Not only there's one less password for them to remember, but it's also safer: the external providers can support Multi-Factor Authentication, and can use advanced tools (often AI-based) to better detect hacked accounts.
### Disable Username and Password authentication
By default, Auth0 offers users the possibility to create a new account specific to your app. You might want to disable that and allow social logins only. (While we recommend doing this, it's entirely optional)
In the Auth0 Management management portal, on the menu on the left side navigate to **Connections**, then **Database**.
In the row for the "Username-Password-Authentication" database, click on "Settings".

Scroll to the bottom of the page and click on the big, red button to remove the connection.

### Configure social logins
In the Auth0 management portal, this time navigate to the **Connections** and then **Social** page.
Here, you can configure all the social login providers, including Google, Facebook, and Microsoft.

Each provider has a different procedure for setting the connection up, and you can follow the Auth0 documentation for instructions.
You can enable any provider you want, and your users will be able to use anyone of them. Because Hereditas whitelists users based on their email addresses, it doesn't matter what provider they use to authenticate, as long as the email address matches.
**Important:** we need providers to return users' email addresses. When you configure a new social provider, make sure that it supports sharing of users' email addresses (not all of them do, e.g. Twitter), and that the **_email_ scope is enabled** when not included in the basic info.
## Next step: Login notifications
In the next step, we'll configure a webhook to send notifications when users sign into your Hereditas box.
}}">Login notifications
================================================
FILE: docs-source/content/guides/build-static-web-app.md
================================================
---
title: Build the static web app
type: docs
---
# Build the static web app
In the previous step we created an Hereditas box, and now we're finally ready to build the static web app.
## Build the web app
You're finally ready to build the static web app, using the [`hereditas build`]({{< relref "/cli/build.md" >}}) command:
````sh
hereditas build
````
This will ask you to **type the _user passphrase_**, which needs to be at least 8 characters long. You can choose any passphrase you'd like, but a good practice is to use a bunch of words in your native language. If you're interested in the subject, check out [XKCD 936](https://www.explainxkcd.com/wiki/index.php/936:_Password_Strength).
Once the command is done, you'll see your generated files in the `dist` folder:
````text
~/hereditas $ ls content
hello.md
photo.webp
subfolder
text.txt
tulips.jpg
~/hereditas $ hereditas build
User passphrase: ***********
Finished building project in dist (took 3.895 seconds)
~/hereditas $ ls dist
1.1.9a2b99a07d39e25b4b7f.js
24ec53f0e99728db2f471caf
35bc79f07f20c003532724bf
430c96dc23ccca5eb4227508
_index
d0160be2ee0f1479367b325c
d9f2eb6a0f34382c36d2a116
hereditas.9a2b99a07d39e25b4b7f.css
hereditas.9a2b99a07d39e25b4b7f.js
index.html
robots.txt
````
> The Hereditas CLI uses [webpack](https://webpack.js.org/) behind the scenes to generate your static app. By default, the JavaScript code is bundled and minified. If you're looking at modifying the Hereditas source and want to skip the minification (for much faster builds) and include a sourcemap, you can call `NODE_ENV=development hereditas build` instead.
## Next step: Managing users
We're almost there. Before you can actually deploy your box (or test it locally), we need to configure the list of users who can unlock it.
}}">Managing users
================================================
FILE: docs-source/content/guides/create-box.md
================================================
---
title: Create the box
type: docs
---
# Create the box
After gathering all the content you want to encrypt, setting up our "API Access" application on Auth0, and configuring a webhook endpoints to send notifications, we can now create a box on our laptop. This will be our "working directory".
## Initialize a working directory
Create a **new, empty folder** on your laptop. Open a terminal inside that folder, then run:
````sh
hereditas init \
--auth0Domain "yourdomain.auth0.com" \
--auth0ClientId "..." \
--auth0ClientSecret "..." \
--url "http://localhost:5000"
````
You'll need to pass some options to the command above:
- `--auth0Domain` is your domain on Auth0, created in the previous step
- Set `--auth0ClientId` and `--auth0ClientSecret` to the Client Id and Client Secret for the "API Access" app you just created in Auth0
- `--url` is the URL where the app will be deployed to. We'll be testing locally before deploying the app, so for now you might just want to keep this to `http://localhost:5000`. We can always change this later, without having to re-build the Hereditas box.
After running the command, you'll see that your folder now contains three objects:
````text
~/hereditas $ ls
content
dist
hereditas.json
welcome.md
````
- The `content` folder is where you store the data you wish to encrypt
- The `dist` folder will contain the generated web app
- The `hereditas.json` file contains the configuration for the Hereditas box
- The `welcome.md` file contains a welcome message that is displayed in the login page; this file is not encrypted.
> In most cases you will not need to manually edit the `hereditas.json` configuration file, as you can use the Hereditas CLI to change the most common options. However, you can find the full reference for the configuration file in the [Configuration file]({{< relref "/advanced/configuration-file.md" >}}) article.
## Content
Place all the content you want to encrypt in the `content` folder. You can store any kind of file in this folder and sub-folders. The [Get started]({{< relref "/guides/get-started.md" >}}#step-zero-gather-all-content) article has some suggestions on what kind of content to store.
Markdown documents are automatically converted to HTML chunks, so that's a great way to include information. However, at present Hereditas web apps do not support hyperlinks, images or videos in Markdown or HTML files linking to other content within the box.
### Welcome file
As mentioned above, Hereditas generates a `welcome.md` file and pre-populates it with some default content.
The welcome file is displayed in the authentication page, and you can use it to provide some information about what your Hereditas box is, and how it can be used.
Note that the welcome file is **not encrypted**, so do not store any confidential information in there!
## Set the webhook URL
We need to set the URL of the webhook we created in the previous step. We can use [`hereditas webhook:set`]({{< relref "/cli/webhook_set.md" >}}) for that, replacing the URL below with yours:
````sh
hereditas webhook:set --url "https://maker.ifttt.com/trigger/hereditas_auth/with/key/123abc456def"
````
## Synchronize changes on Auth0
At this point, let's create the Hereditas application and rules on Auth0, which will also give us the required Client Id.
The Hereditas CLI has a built-in command [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) that manages the application, configuration and rules inside Auth0, in a fully-automated way. So, syncing the changes is as simple as running:
````sh
hereditas auth0:sync
````
The command above will create the application and the rules on Auth0, and make sure that everything is configured correctly. As we'll see in the next steps, you will need to re-run that command after making certain configuration changes.
## Next step: Build the static web app
We're finally ready to use the Hereditas CLI to build our static app! Follow the instructions in the next article for how:
}}">Build the static web app
================================================
FILE: docs-source/content/guides/deploy-box.md
================================================
---
title: Deploy the box
type: docs
---
# Deploy the box
In this last step, we're finally ready to deploy the static web app!
In the [Build the static web app]({{< relref "/guides/build-static-web-app.md" >}}) article you used the Hereditas CLI to generate the box, which is a static, HTML5 web application. The generated files are in the `dist` folder. It's now time to take those files and make them accessible to others.
## Sync changes with Auth0
In case you haven't done it already in the previous step, run the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command to ensure that the Hereditas application and rules are propertly configured on Auth0 (it's safe to run this command as often as you'd like).
````sh
hereditas auth0:sync
````
## Testing locally
Before deploying your app, you can test it running on your laptop with a local server. There are multiple options to run a local server; a simple one is:
````sh
npx serve dist -n
````
This will serve all files in the `dist` directory at the URL `http://localhost:5000`, which you can open with any web browser.
Keep in mind that the URL and port must be whitelisted in the Hereditas app and Auth0. In the previous step, we did whitelist `http://localhost:5000` when running [`hereditas init`]({{< relref "/cli/init.md" >}}), so we're good for now. If you use a local server listening on another port, however, you'll have to allow that URL too – see the [managing deployment URLs](#managing-deployment-urls) section below.
## Choosing where to host your box
Your box is just a static HTML5 web app, with HTML, JavaScript and CSS files, plus a bunch of encrypted documents. You can deploy it on any service capable of serving HTML5 apps via HTTP(S).
Because all of your data is encrypted, Hereditas boxes are designed to be deployed on publicly-accessible endpoints too, safely.
Good solutions include [Azure Blob Storage](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blob-static-website), or [AWS S3](https://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteHosting.html). Any provider that supports static website hosting should work; this service is often free, or very inexpensive.
While possible, we do not recommend deploying Hereditas on a VPS (*what would happen if your credit card got canceled and your services stopped?*), nor inside a server in your home (*would your relatives know how to access it from within your LAN? what if your landlord disconnected your servers, would people know how to rebuild your network?*). Ultimately, however, it's up to you, your specific situation, and to the trust you put in the technical skills of your loved ones.
## Managing deployment URLs
After you've decided where to deploy your app to, you need to whitelist the URL where it will be reachable at. This is necessary because after a successful authentication, Auth0 will redirect users only to URLs you specifically whitelist, for security reasons.
You can manage the list of allowed URLs using the Hereditas CLI, with the commands:
- [`hereditas url:add`]({{< relref "/cli/url_add.md" >}})
- [`hereditas url:list`]({{< relref "/cli/url_list.md" >}})
- [`hereditas url:rm`]({{< relref "/cli/url_rm.md" >}})
For example, let's whitelist `https://myhereditas.example.com` and remove the localhost one we added earlier:
````sh
hereditas url:add --url "https://myhereditas.example.com"
hereditas url:rm --url "http://localhost:5000"
# Verify the list
hereditas url:list
````
After making changes to the list of URLs, sync them with Auth0 so they become effective (you don't need to re-build the Hereditas box, however):
````sh
hereditas auth0:sync
````
## Examples
Here's an example on how to deploy your box to Azure Storage.
### Deploy to Azure Storage
Azure Storage is an object storage provider that offers static website hosting too. You pay for how much data you store, at a rate that starts at less than $0.02 per GB per month.
In order to deploy to Azure Storage, you'll need:
- An Azure account. You can get it [for free](https://azure.com/free) if you don't have one already.
- The Azure CLI installed on your laptop. Installation instructions are in the [official documentation](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest).
Start by logging into Azure and creating an Azure Storage Account:
````sh
# Log into azure
# After running this, follow the instructions to log in via a web browser
az login
# Create a Resource Group and a Storage Account
# The storage account name must be universally unique
# For a list of locations available, type:
# az account list-locations --query "[].{Region:name}" --out table
AZURE_STORAGE_ACCOUNT="myhereditas"
AZURE_RESOURCE_GROUP="Hereditas"
AZURE_LOCATION="eastus2"
az group create \
--name "$AZURE_RESOURCE_GROUP" \
--location "$AZURE_LOCATION"
az storage account create \
--name "$AZURE_STORAGE_ACCOUNT" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--location "$AZURE_LOCATION" \
--sku Standard_LRS \
--kind StorageV2
````
At this point, enable static website hosting for your Storage Account, and retrieve the URL with:
````sh
az storage blob service-properties update \
--account-name "$AZURE_STORAGE_ACCOUNT" \
--static-website \
--404-document "404.html" \
--index-document "index.html"
az storage account show \
--name "$AZURE_STORAGE_ACCOUNT" \
--resource-group "$AZURE_RESOURCE_GROUP" \
--query "primaryEndpoints.web" \
--output tsv
# Result will be something similar to:
# https://myhereditas.z20.web.core.windows.net/
````
We can now upload all files from the `dist` folder into the Storage Account, in the `$web` container:
````sh
az storage blob upload-batch \
--source dist \
--destination "\$web" \
--account-name "$AZURE_STORAGE_ACCOUNT"
````
In the last step, we need to whitelist the website's URL with Auth0:
````sh
# Replace the URL with yours
hereditas url:add --url "https://myhereditas.z20.web.core.windows.net"
hereditas auth0:sync
````
Done! You can now go to `https://myhereditas.z20.web.core.windows.net` and use your Hereditas box.

## Share the information with your relatives
At this point, you have all the information you need to give to your relatives, for usage in case you disappear. Send them a letter, or an email, or anything else that works for you.
Make sure to include:
1. An explanation of what this Hereditas box is, and what information they can find in there.
2. The URL they need to type
3. The *user passphrase*
4. The name of the account they need to use to sign in (the email address)
This is all and only the information they need to use Hereditas.
================================================
FILE: docs-source/content/guides/get-started.md
================================================
---
title: Get started
type: docs
---
# Get started
## Prerequisites
In order to use Hereditas, you will need [Node.js](https://nodejs.org/en/download/) 10 or higher installed on your laptop.
You will also need three services; we'll guide you through the creation of those in the next steps.
1. A (free) [Auth0](https://auth0.com/) account. This is used by Hereditas to ensure that only authorized users can access your data, and only after a certain amount of time after the first request. (But don't worry: Auth0 and their developers have no way to access your data)
2. A webhook that you can use to send you notifications when users log into your Hereditas box, so you know when the unlock timer starts and it gives you a chance to stop it. There are multiple options for that, including [IFTTT](https://ifttt.com/) (free), or more advanced solutions like Microsoft Flow, Azure Functions, AWS Lambda, etc.
3. A place where to host static HTML5 apps (HTML, JavaScript, CSS files, plus your encrypted content) serving it over HTTP(S).
## Install the Hereditas CLI
You can install the Hereditas CLI on your machine from NPM, by running:
````sh
npm install --global hereditas
# Verify it's installed with
hereditas --version
````
> Note: If you prefer not to install the CLI as a global package, you can always invoke it using NPX, for example with `npx hereditas --version`. However, each command invocation will take significantly longer as NPX needs to restore all dependencies.
## Step zero: gather all content
This is the least technical of all the steps, but by far the most important one.
As the owner of an Hereditas box, you start by assembling all the content you want to include in your digital legacy box. For example, text/Markdown documents, images, and other files.
Things you might want to include:
* The passwords to access your laptop and your phone/tablet/watch/etc.
* The recovery key for your password manager, for example iCloud Keychain, 1Password, LastPass, KeePass, etc.
* How to access your private photos on an encrypted drive or cloud storage.
* Useful encryption keys, inclduing keys for your cryptocurrency wallets.
* Or, just a nice letter.
This step is very personal, and Hereditas gives you total flexibility to decide what to include in your box.
While it would technically be possible, we recommend that you don't store large amount of data, or data that changes frequently, inside an Hereditas box. In fact, every time you change any information, you'd have to encrypt and publish again the entire box, which can be very time-consuming, and could lead to your box containing outdated information.
For example, rather than including gigabytes of photos, we recommend that you store them in a safe place (encrypted drive, cloud storage, etc) and use your Hereditas box to explain how to retrieve them. Similarly, instead of including every single password (which can change frequently), just put the recovery key of your password manager.
## Next step: Auth0 setup
After you've installed the Hereditas CLI and gathered all the content, you're ready to go to the next step and configure a new Auth0 account.
}}">Auth0 setup
================================================
FILE: docs-source/content/guides/login-notifications.md
================================================
---
title: Login notifications
type: docs
---
# Login notifications
As the owner of an Hereditas box, you'll want to be notified when someone signs into your box, to potentially block unauthorized attempts. For example, you can choose to receive a text message, or an email, etc.
Hereditas uses [webhooks](https://codeburst.io/what-are-webhooks-b04ec2bf9ca2) for this, which are just POST requests to an external HTTPS endpoint (make sure you use HTTPS, and not HTTP!).
## Notification webhook
Hereditas sends a webhook to the URL you provide, as a POST request with the following JSON body:
````json
{
"value1": "Full notification, e.g. 'New Hereditas login on Fri, 08 Mar 2019 12:01:10 GMT. User: user@example.com (role: user)'",
"value2": "email address of user, e.g. user@example.com",
"value3": "role, either owner or user"
}
````
You can point the webhook to whatever service you'd like to use. The next sections will show some common examples.
### Using IFTTT
[IFTTT](https://ifttt.com/), or "IF This Then That", is a free service that lets you "connect" multiple APIs and actions.
After enabling the [webhook service](https://ifttt.com/maker_webhooks), you'll get a private key. The URL you need to use is:
````text
https://maker.ifttt.com/trigger/{event}/with/key/{key}
````
Replace `{event}` with an event name (e.g. `hereditas_auth`) and `{key}` with your IFTTT Webhook key (so messages are sent to yourself). For example:
````text
https://maker.ifttt.com/trigger/hereditas_auth/with/key/123abc456def
````
Note down your webhook URL, as we'll need it soon.
You can then configure your IFTTT applet to perform any action as a consequence of this. For example, you could send yourself an email, a message on Telegram, or a notification on Slack (or turn the lights red in your home, etc!).
If you send yourself a message, you can use `{{value1}}` as a pre-made mesasge, or you can write whatever body you prefer. As example:
````text
{{Value2}} (role: {{Value3}}) just logged into your Hereditas box at {{OccurredAt}}!
````
## Next step: Create the box
We now have all the information we need to create the Hereditas box in our laptop and start putting content in there.
}}">Create the box
================================================
FILE: docs-source/content/guides/managing-users.md
================================================
---
title: Managing users
type: docs
---
# Managing users
You can use the Hereditas CLI to add or remove authorized users and owners.
## Roles
Hereditas users can have one of two roles:
- **user**: This is the normal user role. When someone with a *user* role signs into your Hereditas box, they are not immediately able to unlock it. Instead, a successful authentication of someone with a *user* role will start the timer, and after a certain delay (e.g. 24 hours, or as you configured it), users can sign in again and this time they'll be able to unlock the box (as long as they know the *user passphrase*).
- **owner**: When an *owner* successfully signs in, two things happen. First, they are always returned the *application token* by Auth0, so they can unlock the Hereditas box at any time (as long as they know the *user passphrase*). Second, an *owner* logging in always stops any running timer. This prevents those with a *user* role from accessing your data when you don't want them to.
## Add or remove authorized users
You can easily manage users with the Hereditas CLI.
To **add users**, use the [`hereditas user:add`]({{< relref "/cli/user_add.md" >}}) command.
````sh
hereditas user:add --email "someone@example.com"
````
By default, all users are given the role *user*. To add an *owner*, use the `--role owner` option:
````sh
hereditas user:add --email "owner@example.com" --role owner
````
You can **list users** who have access to your Hereditas box, and their roles, with the [`hereditas user:list`]({{< relref "/cli/user_list.md" >}}) command.
````sh
hereditas user:list
````
Lastly, you can **remove users** with the [`hereditas user:rm`]({{< relref "/cli/user_rm.md" >}}) command.
````sh
hereditas user:rm --email "someone@example.com"
````
## Synchronize changes on Auth0
The commands above save the changes in the local `hereditas.json` configuration file only.
In order for changes like adding/removing users (and others including changing the wait time, the webhook URL, or re-generating the application token) to be effective, you need to synchronize them with Auth0.
We can use again the [`hereditas auth0:sync`]({{< relref "/cli/auth0_sync.md" >}}) command, which will synchronize all changes in Auth0, updating our rules and application configuration, in a fully-automated way.
````sh
hereditas auth0:sync
````
## Next step: Deploy the box
We're almost there! Ready to test the box locally and then deploy it, so your users can access it when needed.
}}">Deploy the box
================================================
FILE: docs-source/content/introduction/quickstart-video.md
================================================
---
title: Quickstart video
type: docs
---
# Quickstart video
This quickstart video shows you how to get an Hereditas box configured, built and deployed to the cloud, in just a few minutes.
{{< youtube iGgza7AK7ow >}}
> Note: the quickstart video was recorded with Hereditas 0.1. All instructions remain the same for Hereditas 0.2, but the user interface looks better now.
## Get started documentation
For more information, check out our Get Started documentation.
}}">Get started with Hereditas
================================================
FILE: docs-source/content/introduction/security-model.md
================================================
---
title: Security model
type: docs
---
# Security model
This document explains in technical details how Hereditas ensures that your data is protected.
## Data encryption
Hereditas, as a static site generator, encrypts all of your sensitive data with AES-256 in [Galois/Counter Mode (CGM)](https://en.wikipedia.org/wiki/Galois/Counter_Mode), a symmetric cryptographic algorithm that is industry-standard for encrypting data. GCM is an authenticated encryption algorithm, designed to provide both data authenticity and confidentiality.
Each file is encrypted with a unique, random 256-bit key (32 bytes), which is wrapped with a master key (read more below). The wrapped key (40 bytes-long after wrapping) is stored at the beginning of the file.
Additionally, each file is encrypted with a unique, random IV of 12 bytes, which is stored in clear text at the beginning of each file, right after the wrapped key.
Encrypted files are given random names so attackers cannot gather information about the name and type of each file.
## Index file
Each Hereditas box also contains an `_index` file, which is encrypted just like each data file. The file's key is 256-bit, unique and randomly-generated, and it's wrapped with the master key. The wrapped key and the random, unique 12-byte IV are stored at the beginning of the file.
When in cleartext, the index file is a JSON document that contains the original file name, the id of the object stored in the Hereditas box, the authentication tag (as returned by AES-GCM), and a few more details. For more information on the index file, see the [Index file]({{< relref "/advanced/index-file.md" >}}) article in the advanced section.
## Master key
Each file inside the box is encrypted with a unique key that is wrapped with a master key. You can read the next section for details on the key wrapping algorithm used.
The master key is itself derived from the *user passphrase* and the *application token*.
The **user passphrase** is set while running [`hereditas build`]({{< relref "/cli/build.md" >}}). Users need to type it in the Hereditas app before they can unlock the box. The owner can choose any passphrase they want, as long as it's longer than 8 characters. This is not stored anywhere, but you should communicate it (in a safe way) to your loved ones.
The **application token** is unique to each Hereditas box and stored in the `hereditas.json` configuration file. By default, Hereditas generates it when the [`hereditas init`]({{< relref "/cli/init.md" >}}) command is executed, and the token can be re-generated with [`hereditas regenerate-token`]({{< relref "/cli/regenerate-token.md" >}}). The CLI creates the application token by getting 21 random bytes with Node.js [`crypto.randomBytes()`](https://nodejs.org/api/crypto.html#crypto_crypto_randombytes_size_callback), then encoding them as base64. The application token is stored inside Auth0 as a ["rule configuration"](https://auth0.com/docs/rules/guides/configuration), and it's passed to the web app in the JWT token for users that are logged in when they're ready to unlock the box (see below).
The **master key** is derived from the concatenated string (user passphrase + application token) using a key derivation function. Hereditas supports two strong, industry-standard key derivation functions:
- [Argon2](https://en.wikipedia.org/wiki/Argon2) is the default since version 0.2, and it uses the Argon2id variant. Argon2id can use a configurable amount of memory, which can be set in the `hereditas.json` config file; the default is 64 MB.
- [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2), which is based on SHA-512 and supports a configurable number of iterations. The default is 100,000 iterations, and it can be configured with the `hereditas.json` config file.
Both key derivation functions generate a 256-bit key.
Argon2 is the default because it is known to provide better resistance against GPU-based brute force attacks. However, while support for PBKDF2 is available natively in browser thanks to the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), Argon2 uses an [external module](https://github.com/antelle/argon2-browser) based on WebAssembly.
Hereditas uses a salt with the key derivation function (both Argon2 and PBKDF2) that is re-generated on each new build of the box (ie. each time you run [`hereditas build`]({{< relref "/cli/build.md" >}})), and it's stored inside the app's JavaScript file in cleartext.
## Key wrapping
After deriving the master key, Hereditas can use that to wrap and un-wrap the key used to encrypt each file.
As mentioned above, each file's key is a unique, randomly-generated sequence of 32 bytes (256 bits), suitable for AES-256-GCM. This key is wrapped and then stored at the beginning of each file.
File keys are wrapped using the AES-KW algorithm, as defined in RFC 3394. The master key is used as wrapping key.
## Unlocking the box
In order to unlock the box, users need to complete two steps:
1. Authenticate with Auth0. This lets authorized users get the *application token* if appropriate.
2. Type the *user passphrase*.
When users open the Hereditas web app, they are redirected to Auth0 to authenticate themselves.
- Only users whose email address is explicitly whitelisted (using [`hereditas user:add`]({{< relref "/cli/user_add.md" >}})) are allowed to log in. Users can authenticate with any social profile they want (anything supported by Auth0, eg. Google, Facebook, Microsoft accounts, etc), as long as the email address returned by the provider is included in the whitelist.
- Users can have two roles: *owner* and *user*.
- When an **owner** authenticates, Auth0 includes the *application token* in the JWT token every time. Owners who also know the *user passphrase* can unlock their Hereditas boxes any time they want.
- When a normal **user** (ie. non-owner) authenticates the first time, Auth0 sets the time of the login in the Client Application setting, but does not return the *application token*.
- After a configurable amount of time, e.g. 24 hours, users (non-owners) can authenticate again, and this time Auth0 will include the *application token* in the JWT token. The wait time can be configured with [`hereditas wait-time:set`]({{< relref "/cli/wait-time_set.md" >}}). Users can then unlock the Hereditas box if they also know the *user passphrase*.
- If an *owner* authenticates at any time, Auth0 resets any active timer, preventing other users to unlock the Hereditas box when the owner is still around.
## Trustless
The model above is what allows Hereditas to be fully trustless:
1. Users who are in possession of the *user passphrase* cannot unlock Hereditas boxes without the *application token*, even if they have full access to the encrypted files.
2. Auth0 stores only the *application token* and has no knowledge of the *user passphrase*. So, a malicious actor who managed to extract the *application token* from Auth0 would not be able to unlock the Hereditas box.
3. Users need to wait a certain amount of time before they're allowed to unlock Hereditas boxes, and owners can stop the timer by logging in themselves. This guarantees that ill-intentioned users won't be able to unlock Hereditas boxes until you're around.
================================================
FILE: docs-source/content/menu/__template.md
================================================
---
headless: true
---
{{! Set Mustache delimeters to ASP-style tags (this is a Mustache comment) }}
{{=<% %>=}}
* **Introduction**
* [What is Hereditas]({{< relref "/" >}})
* [Quickstart video]({{< relref "/introduction/quickstart-video.md" >}})
* [Security model]({{< relref "/introduction/security-model.md" >}})
* **Guides**
* [Get started]({{< relref "/guides/get-started.md" >}})
* [Auth0 setup]({{< relref "/guides/auth0-setup.md" >}})
* [Login notifications]({{< relref "/guides/login-notifications.md" >}})
* [Create the box]({{< relref "/guides/create-box.md" >}})
* [Build the static web app]({{< relref "/guides/build-static-web-app.md" >}})
* [Managing users]({{< relref "/guides/managing-users.md" >}})
* [Deploy the box]({{< relref "/guides/deploy-box.md" >}})
* **CLI Reference**
<%# index %>
* [<% name %>]({{< relref "/cli/<% path %>" >}})
<%/ index %>
* **Advanced topics**
* [Building self-contained binaries]({{< relref "/advanced/building-self-contained-binaries.md" >}})
* [Auth0 manual configuration]({{< relref "/advanced/auth0-manual-configuration.md" >}})
* [Configuration file]({{< relref "/advanced/configuration-file.md" >}})
* [Index file]({{< relref "/advanced/index-file.md" >}})
* **Other**
* [GitHub project page](https://github.com/ItalyPaleAle/hereditas)
================================================
FILE: docs-source/generate-cli-docs.js
================================================
'use strict'
const Mustache = require('mustache')
const fs = require('fs')
const util = require('util')
// Promisified functions
const readFilePromise = util.promisify(fs.readFile)
const readdirPromise = util.promisify(fs.readdir)
const statPromise = util.promisify(fs.stat)
const writeFilePromise = util.promisify(fs.writeFile)
// Scan a directory recursively and get the file names
const scanFolder = async (base, folder, result) => {
result = result || []
folder = folder || ''
// Scan the list of files and folders, recursively
const list = await readdirPromise(base + folder)
for (const e in list) {
const el = base + folder + list[e]
// Check if it's a directory
const stat = await statPromise(el)
if (!stat) {
continue
}
// If it's a directory, scan it recursively
if (stat.isDirectory()) {
await scanFolder(base, folder + list[e] + '/', result)
}
// Get only JavaScript files
else if (el.substr(-3) === '.js') {
// Add the file to the list
result.push(folder + list[e])
}
}
return result
}
const commandsPath = __dirname + '/../cli/commands/'
const docTemplateFile = __dirname + '/content/cli/__template.md'
const menuTemplateFile = __dirname + '/content/menu/__template.md'
const docDestinationPath = __dirname + '/content/cli/'
const menuDestinationPath = __dirname + '/content/menu/index.md'
// Main entrypoint
;(async function generateCliDocs() {
// Load the templates
const docTemplate = await readFilePromise(docTemplateFile, 'utf8')
Mustache.parse(docTemplate)
// Load the list of CLI commands
const commands = await scanFolder(commandsPath)
// Generate the documentation file for all commands
const promises = commands.map(async (file) => {
// Command name is derived from the file name
const commandName = file.replace('/', ':').slice(0, -3)
// Import the class
const command = require(commandsPath + file)
// Description: first line is the short one, and second line is the long one
const description = command.description.trim()
const [shortDescription, ...parts] = description.split('\n')
const longDescription = parts.join('\n').trim()
// Usage
const usage = (command.usage) ?
'hereditas ' + command.usage.trim() :
'hereditas ' + commandName
// Flags
const flags = []
if (command.flags) {
// Iterate through the flags
for (const key in command.flags) {
if (!command.flags.hasOwnProperty(key) || !command.flags[key]) {
continue
}
const flag = command.flags[key]
if (flag.type !== 'option') {
// eslint-disable-next-line no-console
console.warn('Skipping flag with type != "option"')
continue
}
// Required
const required = flag.required ? '✓' : ''
// Flag name and character
let name = ['--' + key]
if (flag.char) {
name.unshift('-' + flag.char)
}
name = '`' + name.join('` `') + '`'
// Type (including options)
const type = (flag.options) ?
'`"' + flag.options.join('"`, `"') + '"`' :
'string'
const defaultValue = (flag.default) ?
'`"' + flag.default + '"`' :
'none'
// Add the flag
flags.push({
name,
description: flag.description,
required,
defaultValue,
type
})
}
}
// Build the documentation file
const params = {
commandName,
shortDescription,
longDescription,
usage,
hasFlags: !!flags.length,
flags
}
const rendered = Mustache.render(docTemplate, params)
const outfileName = commandName.replace(':', '_') + '.md'
await writeFilePromise(docDestinationPath + outfileName, rendered)
// Return the name of the command and the file, which will be used for the index
return {
name: commandName,
path: outfileName
}
})
const index = await Promise.all(promises)
// Use the index to build the menu
const menuTemplate = await readFilePromise(menuTemplateFile, 'utf8')
const menuRendered = Mustache.render(menuTemplate, {
index
})
await writeFilePromise(menuDestinationPath, menuRendered)
})()
================================================
FILE: docs-source/sync-assets.sh
================================================
#!/bin/sh
set -eu
# "azcopy" command, defaults to searching for it in the PATH
: "${AZCOPYCMD:=$(which azcopy)}"
echo "Using azcopy in $AZCOPYCMD"
# Ensure azcopy is installed
"$AZCOPYCMD" --version
# Check required env vars: $ASSETS, $CONTAINER, $AZURE_STORAGE_ACCOUNT
if [ -z "$ASSETS" ]; then
echo "\$ASSETS is empty"
exit 1
fi
if [ -z "$CONTAINER" ]; then
echo "\$CONTAINER is empty"
exit 1
fi
if [ -z "$AZURE_STORAGE_ACCOUNT" ]; then
echo "\$AZURE_STORAGE_ACCOUNT is empty"
exit 1
fi
echo "Syncing with: https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}"
# Check that all folders specified in $ASSETS exist
for asset in $ASSETS; do
# Ensure the asset exists and it's a folder
if [ ! -d "$asset" ]; then
echo "$asset doesn't exist or it's not a folder"
exit 2
fi
done
# Sync all folders
for asset in $ASSETS; do
# Sync the folder
"$AZCOPYCMD" sync "$asset" https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}/${asset} --recursive --delete-destination=true
done
================================================
FILE: docs-source/themes/book/LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2018 Alex Shpak
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.
================================================
FILE: docs-source/themes/book/README.md
================================================
# Hugo Book Theme
[](https://travis-ci.org/alex-shpak/hugo-book)
### [Hugo](https://gohugo.io) documentation theme as simple as plain book

## Features
* Clean simple design
* Mobile friendly
* Customizable
* Designed to not interfere with other layouts
* Zero initial configuration
## Requirements
* Hugo 0.43 or higher
* Hugo extended version, read more [here](https://gohugo.io/news/0.43-relnotes/)
## Installation
Navigate to your hugo website root and run:
```
git submodule add https://github.com/alex-shpak/hugo-book themes/book
```
Then run hugo (or set `theme: book` in configuration file)
```
hugo server --theme book
```
## Menu
### File tree menu (default)
By default theme will render pages from `content/docs` section as menu in a tree structure.
You can set `title` and `weight` in front matter of pages to adjust order and titles in menu.
### Leaf bundle menu
You can also use leaf bundle and content of it's `index.md` as
menu.
Given you have this file structure
```
├── content
│ ├── docs
│ │ ├── page-one.md
│ │ └── page-two.md
│ └── posts
│ ├── post-one.md
│ └── post-two.md
```
Create file `content/docs/menu/index.md` with content
```md
---
headless: true
---
- [Book Example](/docs/)
- [Page One](/docs/page-one)
- [Page Two](/docs/page-two)
- [Blog](/posts)
```
And Enable it by settings `BookMenuBundle: /docs/menu` in Site configuration
- [Example menu](https://github.com/alex-shpak/hugo-book/blob/master/exampleSite/content/menu/index.md)
- [Example config file](https://github.com/alex-shpak/hugo-book/blob/master/exampleSite/config.yml)
- [Leaf bundles](https://gohugo.io/content-management/page-bundles/)
## Blog
Simple blog supported for section `posts`
## Configuration
### Site Configuration
There are few configuration options you can add to your `config.yml|json|toml` file
```yaml
# (Optional) Set this to true if you use captial letters in file names
disablePathToLower: true
# (Optional) Set this to true to enable 'Last Modified by' date and git author
# information on 'doc' type pages.
enableGitInfo: true
# (Warnings) Theme is intended for documentation use, there for it doesn't render taxonomy.
# You can hide related warning with config below
disableKinds: ["taxonomy", "taxonomyTerm"]
params:
# (Optional, default true) Show or hide table of contents globally
# You can also specify this parameter per page in front matter
BookShowToC: true
# (Optional, default none) Set leaf bundle to render as side menu
# When not specified file structure and weights will be used
BookMenuBundle: /menu
# (Optional, default docs) Specify section of content to render as menu
# You can also set value to "*" to render all sections to menu
BookSection: docs
# This value is duplicate of $link-color for making active link highlight in menu bundle mode
# BookMenuBundleActiveLinkColor: \#004ed0
# Include JS scripts in pages. Disabled by default.
# - Keep side menu on same scroll position during navigation
BookEnableJS: true
# Set source repository location.
# Used for 'Last Modified' and 'Edit this page' links.
BookRepo: https://github.com/alex-shpak/hugo-book
# Enable "Edit this page" links for 'doc' page type.
# Disabled by default. Uncomment to enable. Requires 'BookRepo' param.
# Path must point to 'content' directory of repo.
BookEditPath: edit/master/exampleSite/content
```
### Page Configuration
You can specify additional params per page in front matter
```yaml
---
# Set type to 'docs' if you want to render page outside of configured section or if you render section other than 'docs'
type: docs
# Set page weight to re-arrange items in file-tree menu (if BookMenuBundle not set)
weight: 10
# (Optional) Set to mark page as flat section in file-tree menu (if BookMenuBundle not set)
bookFlatSection: true
# (Optional) Set to hide table of contents, overrides global value
bookShowToC: false
---
```
### Partials
There are few empty partials you can override in `layouts/partials/`
| Partial | Placement |
| -- | -- |
| `layouts/partials/docs/inject/head.html` | Before closing `` tag |
| `layouts/partials/docs/inject/body.html` | Before closing `` tag |
| `layouts/partials/docs/inject/menu-before.html` | At the beginning of `` menu block |
| `layouts/partials/docs/inject/menu-after.html` | At the end of `` menu block |
## Contributing
Contributions are welcome and I will review and consider pull requests.
Primary goals are:
- Keep it simple
- Keep minimal (or zero) default configuration
- Avoid interference with user-defined layouts
Feel free to open issue if you missing some configuration or customization option.
## License
[MIT](LICENSE)
================================================
FILE: docs-source/themes/book/archetypes/docs.md
================================================
---
title: "{{ .Name | humanize | title }}"
weight: 1
---
================================================
FILE: docs-source/themes/book/assets/_markdown.scss
================================================
@import 'variables';
$block-border-radius: 0.15rem;
.markdown {
line-height: 1.7;
> :first-child {
margin-top: 0;
line-height: 1em;
}
h1, h2, h3, h4, h5 {
font-weight: 400;
line-height: 1.25;
}
b, optgroup, strong {
font-weight: 700;
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
code {
font-family: 'Oxygen Mono', monospace;
}
p code {
padding: 0 $padding-4;
background: $gray-200;
border-radius: $block-border-radius;
}
pre {
padding: $padding-16;
background: $gray-100;
border-radius: $block-border-radius;
font-size: $font-size-14;
overflow-x: auto;
}
blockquote {
border-left: $padding-1*2 solid $gray-300;
margin: 0;
padding: $padding-1 $padding-16;
:first-child { margin-top: 0; }
:last-child { margin-bottom: 0; }
}
table tr {
font-size: $font-size-14;
td, th {
padding: $padding-8;
}
}
table {
tr {
td, th {
border-bottom: 1px solid $gray-200;
border-right: 1px solid $gray-200;
&:last-child {
border-right: none;
}
}
&:last-child {
td {
border-bottom: none;
}
}
}
}
}
================================================
FILE: docs-source/themes/book/assets/_utils.scss
================================================
.flex {
display: flex;
}
.justify-start {
justify-content: flex-start;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.align-center {
align-items: center;
}
.mx-auto {
margin: 0 auto;
}
.mr-auto {
margin-right: auto;
}
.hide {
display: none;
}
@mixin fixed {
position: fixed;
top: 0;
bottom: 0;
overflow-x: hidden;
overflow-y: auto;
}
@mixin dark-links {
a {
color: $nav-link-color;
}
a.active {
color: $color-link;
}
}
================================================
FILE: docs-source/themes/book/assets/_variables.scss
================================================
$padding-1: 1px;
$padding-4: 0.25rem;
$padding-8: 0.5rem;
$padding-16: 1rem;
$font-size-base: 16px;
$font-size-12: 0.75rem;
$font-size-14: 0.875rem;
$font-size-16: 1rem;
// Grayscale
$white: #ffffff;
$gray-100: #f8f9fa;
$gray-200: #e9ecef;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #868e96;
$gray-700: #495057;
$gray-800: #343a40;
$gray-900: #212529;
$black: #000;
$color-link: #004ed0;
$color-visited-link: #8440f1;
$color-button: #007bff;
$color-button-active: #0062cc;
$body-background: white;
$body-font-color: $gray-800;
$body-font-weight: 400;
$body-min-width: 25rem;
$nav-background: $body-background;
$nav-link-color: $gray-800;
$header-height: 3.5rem;
$menu-width: 18rem;
$toc-width: 14rem;
$container-min-width: $body-min-width;
$container-max-width: 80rem;
$sm-breakpoint: $menu-width + $body-min-width;
$md-breakpoint: $sm-breakpoint + $toc-width;
================================================
FILE: docs-source/themes/book/assets/book.scss
================================================
@import "variables";
@import "markdown";
@import "utils";
html {
font-size: $font-size-base;
letter-spacing: 0.33px;
scroll-behavior: smooth;
}
html,
body {
min-width: $body-min-width;
overflow-x: hidden;
}
body {
color: $body-font-color;
background: $body-background;
font-family: "Oxygen", sans-serif;
font-weight: $body-font-weight;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
* {
box-sizing: inherit;
}
}
h1,
h2,
h3,
h4,
h5 {
font-weight: 400;
}
a {
text-decoration: none;
color: $color-link;
}
img {
vertical-align: middle;
}
aside nav ul {
padding: 0;
margin: 0;
list-style: none;
li {
margin: 1em 0;
}
a {
display: block;
}
a:hover {
opacity: .5;
}
ul {
padding-left: $padding-16;
}
}
ul.pagination {
display: flex;
justify-content: center;
.page-item a {
padding: $padding-16;
}
}
code {
background-color: $gray-200;
}
// Reset code inside code blocks
.highlight {
code {
background: none;
}
}
.container {
min-width: $container-min-width;
max-width: $container-max-width;
margin: 0 auto;
}
.book-brand {
margin-top: 0;
}
.book-menu {
flex: 0 0 $menu-width;
font-size: $font-size-14;
nav {
width: $menu-width;
padding: $padding-16;
@include fixed;
}
@include dark-links;
}
.book-page {
min-width: $body-min-width;
padding: $padding-16;
// Make images responsive
img {
max-width: 100%;
height: auto;
}
}
.book-header {
margin-bottom: $padding-16;
display: none;
}
.book-toc {
flex: 0 0 $toc-width;
font-size: $font-size-12;
nav {
width: $toc-width;
padding: $padding-16;
@include fixed;
}
nav > ul > li {
margin: 0;
}
}
.book-git-footer {
display: flex;
margin-top: $padding-16;
font-size: $font-size-14;
align-items: baseline;
img {
width: $font-size-14;
vertical-align: bottom;
}
}
.book-posts {
min-width: $body-min-width;
max-width: $sm-breakpoint;
padding: $padding-16;
article {
padding-bottom: $padding-16;
}
}
// Responsive styles
aside nav,
.book-page,
.book-posts,
.markdown {
transition: 0.2s ease-in-out;
transition-property: transform, margin-left, opacity;
will-change: transform, margin-left;
}
@media screen and (max-width: $md-breakpoint) {
.book-toc {
display: none;
}
}
@media screen and (max-width: $sm-breakpoint) {
.book-menu {
margin-left: -$menu-width;
}
.book-header {
display: flex;
}
#menu-control:checked + main {
.book-menu nav,
.book-page,
.book-posts {
transform: translateX($menu-width);
}
.book-header label {
transform: rotate(90deg);
}
.markdown {
opacity: 0.25;
}
}
}
// Extra space for big screens
@media screen and (min-width: $container-max-width) {
.book-page,
.book-menu nav,
.book-toc nav {
padding: $padding-16 * 2 $padding-16;
}
}
// Custom styles
a.hereditas-button {
padding: .375rem .75rem;
line-height: 1.5;
border-radius: .25rem;
display: inline-block;
color: $white;
background-color: $color-button;
border: 1px solid $color-button;
transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
&:hover {
text-decoration: none;
background-color: $color-button-active;
border-color: $color-button-active;
}
}
================================================
FILE: docs-source/themes/book/layouts/404.html
================================================
{{- partial "docs/shared" -}}
{{ partial "docs/html-head" . }}
{{ partial "docs/inject/head" . }}
404 Not Found
{{ partial "docs/inject/body" . }}
================================================
FILE: docs-source/themes/book/layouts/docs/baseof.html
================================================
{{- partial "docs/shared" -}}
{{ partial "docs/html-head" . }}
{{ partial "docs/inject/head" . }}
{{ partial "docs/mobile-header" . }}
{{ template "main" . }}
{{ partial "docs/git-footer" . }}
{{ template "toc" . }}
{{ partial "docs/inject/body" . }}
================================================
FILE: docs-source/themes/book/layouts/docs/list.html
================================================
{{ define "main" }}
{{- .Content -}}
{{ end }}
{{ define "toc" }}
{{ partial "docs/toc" . }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/docs/single.html
================================================
{{ define "main" }}
{{- .Content -}}
{{ end }}
{{ define "toc" }}
{{ partial "docs/toc" . }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/brand.html
================================================
================================================
FILE: docs-source/themes/book/layouts/partials/docs/git-footer.html
================================================
{{ if or .GitInfo .Site.Params.BookEditPath }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/html-head.html
================================================
{{- template "title" . }} | {{ .Site.Title -}}
{{ $styles := resources.Get "book.scss" | resources.ToCSS | resources.Minify | resources.Fingerprint }}
{{ "" | safeHTML }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/inject/body.html
================================================
{{ if eq hugo.Environment "production" }}
{{ with .Site.Params.PlausibleAnalytics }}
{{ end }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/inject/head.html
================================================
================================================
FILE: docs-source/themes/book/layouts/partials/docs/inject/menu-after.html
================================================
================================================
FILE: docs-source/themes/book/layouts/partials/docs/inject/menu-before.html
================================================
================================================
FILE: docs-source/themes/book/layouts/partials/docs/menu-bundle.html
================================================
{{- template "hrefhack" . -}}
{{ with .Site.GetPage .Site.Params.BookMenuBundle }}
{{- .Content -}}
{{ end }}
{{ if .Site.Params.BookEnableJS }}
{{- template "jsmenu" . -}}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/menu-filetree.html
================================================
{{ template "book-get-root-section" . }}
{{- range .Scratch.Get "BookSections" -}}
{{ template "book-section" (dict "Section" . "CurrentPage" $.Permalink) }}
{{- end -}}
{{ define "book-section" }}
{{ range .Section.Sections }}
{{- if .Content -}}
{{ template "book-page-link" (dict "Page" . "CurrentPage" $.CurrentPage) }}
{{- else -}}
{{- template "title" . -}}
{{- end -}}
{{ template "book-section" (dict "Section" . "CurrentPage" $.CurrentPage) }}
{{ end }}
{{ range .Section.Pages }}
{{ template "book-page-link" (dict "Page" . "CurrentPage" $.CurrentPage) }}
{{ end }}
{{ end }}
{{ define "book-page-link" }}
{{- with .Page -}}
{{- template "title" . -}}
{{- end -}}
{{ end }}
{{ define "book-get-root-section" }}
{{ $bookSection := default "docs" .Site.Params.BookSection }}
{{ if eq $bookSection "*" }}
{{ .Scratch.Set "BookSections" .Site.Sections }}
{{ else }}
{{ $bookSections := where .Site.Sections "Section" $bookSection }}
{{ .Scratch.Set "BookSections" $bookSections }}
{{ end }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/menu.html
================================================
{{ partial "docs/brand" . }}
{{ partial "docs/inject/menu-before" . }}
{{ if .Site.Params.BookMenuBundle }}
{{ partial "docs/menu-bundle" . }}
{{ else }}
{{ partial "docs/menu-filetree" . }}
{{ end }}
{{ partial "docs/inject/menu-after" . }}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/mobile-header.html
================================================
================================================
FILE: docs-source/themes/book/layouts/partials/docs/shared.html
================================================
{{/*These templates contains some more complex logic and shared between partials*/}}
{{- define "title" -}}
{{- if .Pages -}}
{{ $sections := split (trim .Dir "/") "/" }}
{{ $title := index ($sections | last 1) 0 | humanize | title }}
{{- default $title .Title -}}
{{- else -}}
{{ $title := .File | humanize | title }}
{{- default $title .Title -}}
{{- end -}}
{{- end -}}
{{- define "hrefhack" -}}
{{ $attrEq := "$=" }}
{{ $attrVal := .RelPermalink }}
{{ if eq .RelPermalink "/" }}
{{ $attrEq = "=" }}
{{ $attrVal = .Permalink }}
{{ end }}
{{- end -}}
{{- define "jsmenu" -}}
{{- end -}}
================================================
FILE: docs-source/themes/book/layouts/partials/docs/toc.html
================================================
{{ $showToC := default (default true .Site.Params.BookShowToC) .Params.bookshowtoc }}
{{ if and ($showToC) (.Page.TableOfContents) }}
{{ .Page.TableOfContents }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/posts/baseof.html
================================================
{{- partial "docs/shared" -}}
{{ partial "docs/html-head" . }}
{{ partial "docs/inject/head" . }}
{{ partial "docs/mobile-header" . }}
{{ template "main" . }}
{{ partial "docs/inject/body" . }}
================================================
FILE: docs-source/themes/book/layouts/posts/list.html
================================================
{{ define "main" }}
{{ $paginator := .Paginate (where .Pages "Params.hidden" "ne" true) }}
{{ range sort .Paginator.Pages }}
{{ .Date.Format "January 2, 2006" }}
{{- .Summary -}}
{{ if .Truncated }}
...
{{ end }}
{{ end }}
{{ template "_internal/pagination.html" . }}
{{ end }}
================================================
FILE: docs-source/themes/book/layouts/posts/single.html
================================================
{{ define "main" }}
{{ .Title }}
{{ .Date.Format "January 2, 2006" }}
{{- .Content -}}
{{ end }}
================================================
FILE: docs-source/themes/book/source
================================================
https://github.com/alex-shpak/hugo-book/tree/fdc6fdd2de1bcb5a891fd3e76c7d4cacb596ee09
================================================
FILE: docs-source/themes/book/theme.toml
================================================
# theme.toml template for a Hugo theme
# See https://github.com/gohugoio/hugoThemes#themetoml for an example
name = "Book"
license = "MIT"
licenselink = "https://github.com/alex-shpak/hugo-book/blob/master/LICENSE"
description = "Hugo documentation theme as simple as plain book"
homepage = "https://github.com/alex-shpak/hugo-book"
tags = ["responsive", "clean", "documentation", "docs", "flexbox"]
features = []
min_version = "0.43"
[author]
name = "Alex Shpak"
homepage = "https://github.com/alex-shpak/"
================================================
FILE: docs-source/workers-site/.cargo-ok
================================================
================================================
FILE: docs-source/workers-site/.gitignore
================================================
node_modules
worker
================================================
FILE: docs-source/workers-site/assets.js
================================================
export default [
{
match: /^\/images\/(.*?)$/,
storagePath: '/public/images/$1',
// Cache in the edge for 3 months
edgeTTL: 86400 * 90,
// Cache in the browser for 2 weeks
browserTTL: 86400 * 14,
// Not immutable
immutable: false
},
{
match: /^\/svg\/(.*?)$/,
storagePath: '/public/svg/$1',
// Cache in the edge for 3 months
edgeTTL: 86400 * 90,
// Cache in the browser for 1 month
browserTTL: 86400 * 30,
// Not immutable
immutable: false
},
]
================================================
FILE: docs-source/workers-site/cache-config.js
================================================
// Configure how to cache files from KV
// Match by path
export const cachePaths = [
{
// JS and CSS files
// They have a unique hash in the file name
match: /^\/(js|css)\/(.*?)$/,
// Cache in the edge for 6 months
edgeTTL: 86400 * 180,
// Cache in the browser for 6 months
browserTTL: 86400 * 180,
// Not immutable
immutable: false
}
]
// Default
export const cacheDefault = {
// Cache in the edge for 5 minutes
edgeTTL: 300,
// Cache in the browser for 10 minutes
browserTTL: 600,
// Not immutable
immutable: false
}
/**
* Returns the cache settings for a given URL.
* @param {string} url - URL of the original request
* @returns {CacheOpts}
*/
export function cacheSettings(urlStr) {
// Convert to an URL object
const url = new URL(urlStr)
if (!url || !url.host) {
return null
}
// Check if there are special cache settings for this URL
for (let i = 0; i < cachePaths.length; i++) {
const e = cachePaths[i]
if (!e || !e.match) {
continue
}
const match = url.pathname.match(e.match)
if (!match) {
continue
}
// Return the request URL and caching options
return {
edgeTTL: e.edgeTTL,
browserTTL: e.browserTTL,
immutable: !!e.immutable
}
}
return cacheDefault
}
/**
* @typedef CacheOpts
* @type {Object}
* @property {number} [edgeTTL]
* @property {number} [browserTTL]
* @property {boolean} [immutable]
*/
================================================
FILE: docs-source/workers-site/index.js
================================================
import {getAssetFromKV} from '@cloudflare/kv-asset-handler'
import assets from './assets'
import {cacheSettings} from './cache-config'
/* global STORAGE_ACCOUNT, STORAGE_CONTAINER, DOMAINS, PLAUSIBLE_ANALYTICS */
/**
* The DEBUG flag will do two things that help during development:
* 1. we will skip caching on the edge, which makes it easier to debug
* 2. we will return an error message on exception in your Response rather than the default 404.html page
*/
const DEBUG = false
addEventListener('fetch', (event) => {
try {
event.respondWith(handleEvent(event))
}
catch (e) {
if (DEBUG) {
return event.respondWith(
new Response(e.message || e.toString(), {
status: 500,
}),
)
}
event.respondWith(new Response('Internal Error', {status: 500}))
}
})
/**
* Handles requests coming in from the client
* @param {Event} event
* @returns {Promise} Response object
*/
async function handleEvent(event) {
const reqUrl = new URL(event.request.url)
// Check if the URL points to a static asset on Azure Storage
const useAsset = isAsset(reqUrl)
if (useAsset) {
return requestAsset(useAsset)
}
// Handle proxy for Plausible if enabled (if PLAUSIBLE_ANALYTICS contains the URL of the Plausible server, with https prefix)
// 1. Proxy and cache the script (from /pls/index.*.js to ${PLAUSIBLE_ANALYTICS}/js/plausible.outbound-links.js)
// 2. Proxy (no cache) the message sending the request (from /pls/(event|error) to ${PLAUSIBLE_ANALYTICS}/api/(event|error))
// Check if the URL is for the Plausible Analytics script
const path = reqUrl.pathname
if (PLAUSIBLE_ANALYTICS && path) {
// Script
if (/^\/pls\/index(\.[a-fA-F0-9]{1,6})?\.js$/.test(path)) {
// Request the asset and modify the response to add padding
return requestAsset(
{
url: PLAUSIBLE_ANALYTICS + '/js/plausible.outbound-links.js',
// Cache in the edge for a day and in the browser for 12 hours
edgeTTL: 86400,
browserTTL: 43200
},
async (response) => {
// Get the body's text and add padding
let text = await response.text()
const num = Math.floor(Math.random() * 100000)
if (Math.random() < 0.5) {
text += `\n;'` + num + `'`
} else {
text = `'` + num + `';\n` + text
}
return text
}
)
}
// APIs
if (path.startsWith('/pls/api')) {
// Clone the request but change the URL
console.log(PLAUSIBLE_ANALYTICS + path.slice(4))
const newReq = new Request(
PLAUSIBLE_ANALYTICS + path.slice(4),
new Request(event.request, {})
)
// Set the X-Forwarded-For header
// Cloudflare automatically adds X-Real-IP and CF-Connecting-IP (and X-Forwarded-Proto), but we need X-Forwarded-For too
// First, check if the request had an X-Forwarded-For already
if (!newReq.headers.get('X-Forwarded-For')) {
// Fallback to CF-Connecting-IP if available
// Lastly, X-Real-IP
if (newReq.headers.get('CF-Connecting-IP')) {
newReq.headers.set('X-Forwarded-For', newReq.headers.get('CF-Connecting-IP'))
} else if (newReq.headers.get('X-Real-IP')) {
newReq.headers.set('X-Forwarded-For', newReq.headers.get('X-Real-IP'))
}
}
// Need to remove all Cloudflare headers (starting with cf-) and the Host and Cookie headers, or the request will fail
newReq.headers.delete('Host')
newReq.headers.delete('Cookie')
for (const key of newReq.headers.keys()) {
if (key.startsWith('cf-')) {
newReq.headers.delete(key)
}
}
// Make the request
return fetch(newReq)
}
}
// Request from the KV
return requestFromKV(event)
}
/**
* Loads the response from the Workers KV
* @param {Event} event
* @returns {Promise} Response object
*/
async function requestFromKV(event) {
// Get cache settings for this file
const cacheOpts = cacheSettings(event.request.url)
// Options for the request from the KV
/** @type {import('@cloudflare/kv-asset-handler').Options} */
const options = {
// Set custom caching options
cacheControl: {
// Add the options
...cacheOpts,
// Use Cloudflare cache
bypassCache: false,
}
}
if (DEBUG) {
// Disable caching while in debug mode
options.cacheControl = {
bypassCache: true,
browserTTL: null,
}
}
try {
const response = await getAssetFromKV(event, options)
// Set the Cache-Control header for the browser
setCacheHeader(response.status, response.headers, cacheOpts)
// Set security headers
setSecurityHeaders(response.headers)
return response
}
catch (e) {
// If an error is thrown try to serve the asset at 404.html
if (!DEBUG) {
try {
const notFoundResponse = await getAssetFromKV(event, {
mapRequestToAsset: req => new Request((new URL(req.url).origin) + '/404.html', req),
})
return new Response(notFoundResponse.body, {...notFoundResponse, status: 404})
}
// eslint-disable-next-line no-empty
catch (e) {}
}
return new Response(e.message || e.toString(), {status: 500})
}
}
/**
* Requests an asset, optionally caching it in the edge. It also sets the correct headers in the response.
* @param {object} useAsset
* @param {(response: Response) => Promise} [modifyBody] Optional method that can modify the response's body
* @returns {Response} A Response object
*/
async function requestAsset(useAsset, modifyBody) {
// Caching options
const cfOpts = {}
if (useAsset.edgeTTL) {
// Cache everything, even if the response has no TTL
cfOpts.cacheEverything = true
cfOpts.cacheTtlByStatus = {
'200-299': useAsset.edgeTTL,
404: 3,
'500-599': 0
}
}
// Return a fetch invocation (promise) that retrieves data from the origin
let response = await fetch(useAsset.url, {
cf: cfOpts
})
// See if we want to modify the response's body
let body = response.body
if (modifyBody) {
body = await modifyBody(response)
}
// Reconstruct the Response object to make its headers mutable
response = new Response(body, response)
// Delete all Azure Storage headers (x-ms-*)
for (const key of response.headers.keys()) {
if (key.startsWith('x-ms-')) {
response.headers.delete(key)
}
}
// Set the Cache-Control header for the browser
setCacheHeader(response.status, response.headers, useAsset)
// Set security headers
setSecurityHeaders(response.headers)
// Return the data we requested (and cached)
return response
}
/**
* Sets the Cache-Control header for the browser if needed
* @param {number} statusCode
* @param {Headers} headers
* @param {CacheOpts} cacheOpts
*/
function setCacheHeader(statusCode, headers, cacheOpts) {
if (statusCode >= 200 && statusCode <= 299 && cacheOpts.browserTTL) {
let val = 'public,max-age=' + cacheOpts.browserTTL
if (cacheOpts.immutable) {
val += ',immutable'
}
headers.set('Cache-Control', val)
} else {
headers.delete('Cache-Control')
}
}
/**
* Sets some security headers
* @param {Headers} headers
*/
function setSecurityHeaders(headers) {
// Opt out of FLoC
let policy = headers.get('Permissions-Policy')
policy = (policy ? policy + '; ' : '') + 'interest-cohort=()'
headers.set('Permissions-Policy', policy)
// Referrer policy
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
// Set CSP (and X-Frame-Options) for HTML pages only
const ct = headers.get('Content-Type')
if (ct && ct == 'text/html' || ct.startsWith('text/html;')) {
// Allow using in frames on the same origin only
headers.set('X-Frame-Options', 'SAMEORIGIN')
}
}
/**
* Check if the requested URL corresponds to an asset in Azure Storage
* @param {URL} url - URL of the original request
*/
function isAsset(url) {
for (let i = 0; i < assets.length; i++) {
const e = assets[i]
if (!e || !e.match) {
continue
}
const match = url.pathname.match(e.match)
if (!match) {
continue
}
// New request URL
const assetUrl = 'https://' + STORAGE_ACCOUNT + '.blob.core.windows.net/' + STORAGE_CONTAINER + e.storagePath.replace(/\$([1-9][0-9]*)/g, (m) => {
const index = parseInt(m.substr(1), 10)
return match[index] || ''
})
// Return the request URL and caching options
return {
url: assetUrl,
edgeTTL: e.edgeTTL,
browserTTL: e.browserTTL,
immutable: !!e.immutable
}
}
return false
}
================================================
FILE: docs-source/workers-site/package.json
================================================
{
"private": true,
"name": "worker",
"version": "1.0.0",
"description": "A template for kick starting a Cloudflare Workers project",
"main": "index.js",
"author": "Ashley Lewis ",
"license": "MIT",
"dependencies": {
"@cloudflare/kv-asset-handler": "^0.1.3"
}
}
================================================
FILE: docs-source/wrangler.toml
================================================
compatibility_date = "2021-11-08"
type = "webpack"
name = "hereditas-dev"
# Dev environment is deployed to a workers.dev domain
workers_dev = true
# Use CF_ACCOUNT_ID instead
#account_id = ""
# Use CF_ZONE_ID instead
#zone_id = ""
vars = {STORAGE_CONTAINER = "hereditas-dev", DOMAINS = "hereditas-dev.italypaleale.workers.dev"}
# These variables need to be set as secrets: PLAUSIBLE_ANALYTICS (except for dev), STORAGE_ACCOUNT
[site]
bucket = "./public"
entry-point = "workers-site"
[env.staging]
name = "hereditas-staging"
workers_dev = true
route = "staging.hereditas.app/*"
vars = {STORAGE_CONTAINER = "hereditas-staging", DOMAINS = "staging.hereditas.app"}
[env.production]
name = "hereditas"
workers_dev = true
route = "hereditas.app/*"
vars = {STORAGE_CONTAINER = "hereditas-prod", DOMAINS = "hereditas.app"}
================================================
FILE: package.json
================================================
{
"name": "hereditas",
"version": "0.2.2",
"author": "Alessandro Segala @ItalyPaleAle",
"bin": {
"hereditas": "./bin/run"
},
"bugs": "https://github.com/ItalyPaleAle/hereditas/issues",
"dependencies": {
"@fullhuman/postcss-purgecss": "2.3.0",
"@oclif/command": "1.8.0",
"@oclif/config": "1.17.0",
"@oclif/plugin-help": "3.2.0",
"argon2-browser": "1.14.0",
"auth0": "2.27.1",
"autoprefixer": "9.8.6",
"base64-loader": "1.0.0",
"buffer-equal-constant-time": "1.0.1",
"buffer-xor": "2.0.2",
"cli-ux": "5.4.10",
"copy-webpack-plugin": "6.0.3",
"css-loader": "4.2.1",
"html-webpack-plugin": "4.3.0",
"idtoken-verifier": "2.0.3",
"lodash.clonedeep": "4.5.0",
"lodash.defaultsdeep": "4.6.1",
"marked": "1.1.1",
"mini-css-extract-plugin": "0.10.0",
"path-to-regexp": "6.1.0",
"postcss": "7.0.32",
"postcss-import": "12.0.1",
"postcss-loader": "3.0.0",
"qs": "6.9.4",
"smhelper": "1.2.4",
"style-loader": "1.2.1",
"svelte": "3.24.1",
"svelte-loader": "2.13.6",
"svelte-spa-router": "2.2.0",
"tailwindcss": "1.7.1",
"webpack": "4.44.1",
"webpack-subresource-integrity": "1.4.1"
},
"devDependencies": {
"@oclif/dev-cli": "1.22.2",
"eslint": "7.7.0",
"eslint-plugin-html": "6.0.3",
"eslint-plugin-svelte3": "2.7.3",
"globby": "11.0.1",
"mustache": "4.0.1",
"serve": "11.3.2"
},
"engines": {
"node": ">=10.0.0"
},
"files": [
"/app",
"/auth0",
"/bin",
"/cli",
"/npm-shrinkwrap.json",
"/oclif.manifest.json",
"/vendor"
],
"homepage": "https://github.com/ItalyPaleAle/hereditas",
"keywords": [
"digital legacy",
"cli",
"generator"
],
"license": "GPL-3.0-only",
"main": "cli/index.js",
"oclif": {
"commands": "./cli/commands",
"bin": "hereditas",
"plugins": [
"@oclif/plugin-help"
],
"topics": {
"auth0": {
"description": "interact with Auth0 to configure the Hereditas client"
},
"url": {
"description": "manage URLs where the Hereditas box will be deployed, which are used for OAuth callbacks"
},
"user": {
"description": "manage the list of owners and users of the Hereditas box"
},
"wait-time": {
"description": "manage the waitTime setting of the hereditas.json file"
},
"webhook": {
"description": "manage the webhook setting of the hereditas.json file"
}
}
},
"repository": "ItalyPaleAle/hereditas",
"scripts": {
"postpack": "rm -f oclif.manifest.json",
"eslint": "npx eslint -c .eslintrc.js --ext .js,.svelte,.html .",
"prepack": "oclif-dev manifest",
"test": "echo NO TESTS"
}
}