aladinnetwork-blockstack
Version:
The Aladin Javascript library for authentication, identity, and storage.
722 lines (678 loc) • 24.3 kB
text/typescript
import {
getFullReadUrl,
connectToGaiaHub, uploadToGaiaHub, getBucketUrl, ALADIN_GAIA_HUB_LABEL,
GaiaHubConfig,
deleteFromGaiaHub
} from './hub'
// export { type GaiaHubConfig } from './hub'
import {
encryptECIES, decryptECIES, signECDSA, verifyECDSA
} from '../encryption/ec'
import { getPublicKeyFromPrivate, publicKeyToAddress } from '../keys'
import { lookupProfile } from '../profiles/profileLookup'
import {
InvalidStateError,
SignatureVerificationError
} from '../errors'
import { Logger } from '../logger'
import { UserSession } from '../auth/userSession'
import { getGlobalObject } from '../utils'
import { fetchPrivate } from '../fetchUtil'
/**
* Specify a valid MIME type, encryption, and whether to sign the [[UserSession.putFile]].
*/
export interface PutFileOptions {
/**
* Encrypt the data with the app public key.
* If a string is specified, it is used as the public key.
* If the boolean `true` is specified then the current user's app public key is used.
* @default true
*/
encrypt?: boolean | string;
/**
* Sign the data using ECDSA on SHA256 hashes with the user's app private key.
* If a string is specified, it is used as the private key.
* @default false
*/
sign?: boolean | string;
/**
* Set a Content-Type header for unencrypted data.
*/
contentType?: string;
}
const SIGNATURE_FILE_SUFFIX = '.sig'
/**
* Fetch the public read URL of a user file for the specified app.
* @param {String} path - the path to the file to read
* @param {String} username - The Aladin ID of the user to look up
* @param {String} appOrigin - The app origin
* @param {String} [zoneFileLookupURL=null] - The URL
* to use for zonefile lookup. If falsey, this will use the
* aladin.js's [[getNameInfo]] function instead.
* @return {Promise<string>} that resolves to the public read URL of the file
* or rejects with an error
*/
export async function getUserAppFileUrl(
path: string, username: string, appOrigin: string,
zoneFileLookupURL?: string
): Promise<string|null> {
const profile = await lookupProfile(username, zoneFileLookupURL)
let bucketUrl: string = null
if (profile.hasOwnProperty('apps')) {
if (profile.apps.hasOwnProperty(appOrigin)) {
const url = profile.apps[appOrigin]
const bucket = url.replace(/\/?(\?|#|$)/, '/$1')
bucketUrl = `${bucket}${path}`
}
}
return bucketUrl
}
/**
*
*
* @deprecated
* #### v19 Use [[UserSession.encryptContent]].
*
* Encrypts the data provided with the app public key.
* @param {String|Buffer} content - data to encrypt
* @param {Object} [options=null] - options object
* @param {String} options.publicKey - the hex string of the ECDSA public
* key to use for encryption. If not provided, will use user's appPublicKey.
* @return {String} Stringified ciphertext object
*/
export function encryptContent(
content: string | Buffer,
options?: {
publicKey?: string
},
caller?: UserSession
) {
const opts = Object.assign({}, options)
if (!opts.publicKey) {
const privateKey = (caller || new UserSession()).loadUserData().appPrivateKey
opts.publicKey = getPublicKeyFromPrivate(privateKey)
}
const cipherObject = encryptECIES(opts.publicKey, content)
return JSON.stringify(cipherObject)
}
/**
*
* @deprecated
* #### v19 Use [[UserSession.decryptContent]].
*
* Decrypts data encrypted with `encryptContent` with the
* transit private key.
* @param {String|Buffer} content - encrypted content.
* @param {Object} [options=null] - options object
* @param {String} options.privateKey - the hex string of the ECDSA private
* key to use for decryption. If not provided, will use user's appPrivateKey.
* @return {String|Buffer} decrypted content.
*/
export function decryptContent(
content: string,
options?: {
privateKey?: string
},
caller?: UserSession
) {
const opts = Object.assign({}, options)
if (!opts.privateKey) {
opts.privateKey = (caller || new UserSession()).loadUserData().appPrivateKey
}
try {
const cipherObject = JSON.parse(content)
return decryptECIES(opts.privateKey, cipherObject)
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error('Failed to parse encrypted content JSON. The content may not '
+ 'be encrypted. If using getFile, try passing { decrypt: false }.')
} else {
throw err
}
}
}
/* Get the gaia address used for servicing multiplayer reads for the given
* (username, app) pair.
* @private
* @ignore
*/
async function getGaiaAddress(
app: string, username?: string, zoneFileLookupURL?: string,
caller?: UserSession
): Promise<string> {
const opts = normalizeOptions({ app, username }, caller)
let fileUrl: string
if (username) {
fileUrl = await getUserAppFileUrl('/', opts.username, opts.app, zoneFileLookupURL)
} else {
if (!caller) {
caller = new UserSession()
}
const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection()
fileUrl = await getFullReadUrl('/', gaiaHubConfig)
}
const matches = fileUrl.match(/([13][a-km-zA-HJ-NP-Z0-9]{26,35})/)
if (!matches) {
throw new Error('Failed to parse gaia address')
}
return matches[matches.length - 1]
}
/**
* @param {Object} [options=null] - options object
* @param {String} options.username - the Aladin ID to lookup for multi-player storage
* @param {String} options.app - the app to lookup for multi-player storage -
* defaults to current origin
*
* @ignore
*/
function normalizeOptions<T>(
options?: {
app?: string,
username?: string
} & T,
caller?: UserSession
) {
const opts = Object.assign({}, options)
if (opts.username) {
if (!opts.app) {
const appConfig = (caller || new UserSession()).appConfig
if (!appConfig) {
throw new InvalidStateError('Missing AppConfig')
}
opts.app = appConfig.appDomain
}
}
return opts
}
/**
* @deprecated
* #### v19 Use [[UserSession.getFileUrl]] instead.
*
* @param {String} path - the path to the file to read
* @returns {Promise<string>} that resolves to the URL or rejects with an error
*/
export async function getFileUrl(
path: string,
options?: GetFileUrlOptions,
caller?: UserSession
): Promise<string> {
const opts = normalizeOptions(options, caller)
let readUrl: string
if (opts.username) {
readUrl = await getUserAppFileUrl(path, opts.username, opts.app, opts.zoneFileLookupURL)
} else {
const gaiaHubConfig = await (caller || new UserSession()).getOrSetLocalGaiaHubConnection()
readUrl = await getFullReadUrl(path, gaiaHubConfig)
}
if (!readUrl) {
throw new Error('Missing readURL')
} else {
return readUrl
}
}
/* Handle fetching the contents from a given path. Handles both
* multi-player reads and reads from own storage.
* @private
* @ignore
*/
function getFileContents(path: string, app: string, username: string | undefined,
zoneFileLookupURL: string | undefined,
forceText: boolean,
caller?: UserSession): Promise<string | ArrayBuffer | null> {
return Promise.resolve()
.then(() => {
const opts = { app, username, zoneFileLookupURL }
return getFileUrl(path, opts, caller)
})
.then(readUrl => fetchPrivate(readUrl))
.then<string | ArrayBuffer | null>((response) => {
if (response.status !== 200) {
if (response.status === 404) {
Logger.debug(`getFile ${path} returned 404, returning null`)
return null
} else {
throw new Error(`getFile ${path} failed with HTTP status ${response.status}`)
}
}
const contentType = response.headers.get('Content-Type')
if (forceText || contentType === null
|| contentType.startsWith('text')
|| contentType === 'application/json') {
return response.text()
} else {
return response.arrayBuffer()
}
})
}
/* Handle fetching an unencrypted file, its associated signature
* and then validate it. Handles both multi-player reads and reads
* from own storage.
* @private
* @ignore
*/
function getFileSignedUnencrypted(path: string, opt: GetFileOptions, caller?: UserSession) {
// future optimization note:
// in the case of _multi-player_ reads, this does a lot of excess
// profile lookups to figure out where to read files
// do browsers cache all these requests if Content-Cache is set?
return Promise.all(
[getFileContents(path, opt.app, opt.username, opt.zoneFileLookupURL, false, caller),
getFileContents(`${path}${SIGNATURE_FILE_SUFFIX}`, opt.app, opt.username,
opt.zoneFileLookupURL, true, caller),
getGaiaAddress(opt.app, opt.username, opt.zoneFileLookupURL, caller)]
)
.then(([fileContents, signatureContents, gaiaAddress]) => {
if (!fileContents) {
return fileContents
}
if (!gaiaAddress) {
throw new SignatureVerificationError('Failed to get gaia address for verification of: '
+ `${path}`)
}
if (!signatureContents || typeof signatureContents !== 'string') {
throw new SignatureVerificationError('Failed to obtain signature for file: '
+ `${path} -- looked in ${path}${SIGNATURE_FILE_SUFFIX}`)
}
let signature
let publicKey
try {
const sigObject = JSON.parse(signatureContents)
signature = sigObject.signature
publicKey = sigObject.publicKey
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error('Failed to parse signature content JSON '
+ `(path: ${path}${SIGNATURE_FILE_SUFFIX})`
+ ' The content may be corrupted.')
} else {
throw err
}
}
const signerAddress = publicKeyToAddress(publicKey)
if (gaiaAddress !== signerAddress) {
throw new SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't`
+ ` match gaia address (${gaiaAddress})`)
} else if (!verifyECDSA(fileContents, publicKey, signature)) {
throw new SignatureVerificationError(
'Contents do not match ECDSA signature: '
+ `path: ${path}, signature: ${path}${SIGNATURE_FILE_SUFFIX}`
)
} else {
return fileContents
}
})
}
/* Handle signature verification and decryption for contents which are
* expected to be signed and encrypted. This works for single and
* multiplayer reads. In the case of multiplayer reads, it uses the
* gaia address for verification of the claimed public key.
* @private
* @ignore
*/
function handleSignedEncryptedContents(caller: UserSession, path: string, storedContents: string,
app: string, username?: string, zoneFileLookupURL?: string) {
const appPrivateKey = caller.loadUserData().appPrivateKey
const appPublicKey = getPublicKeyFromPrivate(appPrivateKey)
let addressPromise: Promise<string>
if (username) {
addressPromise = getGaiaAddress(app, username, zoneFileLookupURL, caller)
} else {
const address = publicKeyToAddress(appPublicKey)
addressPromise = Promise.resolve(address)
}
return addressPromise.then((address) => {
if (!address) {
throw new SignatureVerificationError('Failed to get gaia address for verification of: '
+ `${path}`)
}
let sigObject
try {
sigObject = JSON.parse(storedContents)
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error('Failed to parse encrypted, signed content JSON. The content may not '
+ 'be encrypted. If using getFile, try passing'
+ ' { verify: false, decrypt: false }.')
} else {
throw err
}
}
const signature = sigObject.signature
const signerPublicKey = sigObject.publicKey
const cipherText = sigObject.cipherText
const signerAddress = publicKeyToAddress(signerPublicKey)
if (!signerPublicKey || !cipherText || !signature) {
throw new SignatureVerificationError(
'Failed to get signature verification data from file:'
+ ` ${path}`
)
} else if (signerAddress !== address) {
throw new SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't`
+ ` match gaia address (${address})`)
} else if (!verifyECDSA(cipherText, signerPublicKey, signature)) {
throw new SignatureVerificationError('Contents do not match ECDSA signature in file:'
+ ` ${path}`)
} else {
return caller.decryptContent(cipherText)
}
})
}
export interface GetFileUrlOptions {
/**
* The Aladin ID to lookup for multi-player storage.
* If not specified, the currently signed in username is used.
*/
username?: string;
/**
* The app to lookup for multi-player storage - defaults to current origin.
* @default `window.location.origin`
* Only if available in the executing environment, otherwise `undefined`.
*/
app?: string;
/**
* The URL to use for zonefile lookup. If falsey, this will use
* the aladin.js's [[getNameInfo]] function instead.
*/
zoneFileLookupURL?: string;
}
/**
* Used to pass options to [[UserSession.getFile]]
*/
export interface GetFileOptions extends GetFileUrlOptions {
/**
* Try to decrypt the data with the app private key.
* @default true
*/
decrypt?: boolean;
/**
* Whether the content should be verified, only to be used
* when [[UserSession.putFile]] was set to `sign = true`.
* @default false
*/
verify?: boolean;
}
/**
* Retrieves the specified file from the app's data store.
* @param {String} path - the path to the file to read
* @returns {Promise} that resolves to the raw data in the file
* or rejects with an error
*/
export function getFile(
path: string,
options?: GetFileOptions,
caller?: UserSession
) {
const defaults: GetFileOptions = {
decrypt: true,
verify: false,
username: null,
app: getGlobalObject('location', { returnEmptyObject: true }).origin,
zoneFileLookupURL: null
}
const opt = Object.assign({}, defaults, options)
if (!caller) {
caller = new UserSession()
}
// in the case of signature verification, but no
// encryption expected, need to fetch _two_ files.
if (opt.verify && !opt.decrypt) {
return getFileSignedUnencrypted(path, opt, caller)
}
return getFileContents(path, opt.app, opt.username, opt.zoneFileLookupURL, !!opt.decrypt, caller)
.then<string|ArrayBuffer|Buffer>((storedContents) => {
if (storedContents === null) {
return storedContents
} else if (opt.decrypt && !opt.verify) {
if (typeof storedContents !== 'string') {
throw new Error('Expected to get back a string for the cipherText')
}
return caller.decryptContent(storedContents)
} else if (opt.decrypt && opt.verify) {
if (typeof storedContents !== 'string') {
throw new Error('Expected to get back a string for the cipherText')
}
return handleSignedEncryptedContents(caller, path, storedContents,
opt.app, opt.username, opt.zoneFileLookupURL)
} else if (!opt.verify && !opt.decrypt) {
return storedContents
} else {
throw new Error('Should be unreachable.')
}
})
}
/**
* Stores the data provided in the app's data store to to the file specified.
* @param {String} path - the path to store the data in
* @param {String|Buffer} content - the data to store in the file
* @return {Promise} that resolves if the operation succeed and rejects
* if it failed
*/
export async function putFile(
path: string,
content: string | Buffer,
options?: PutFileOptions,
caller?: UserSession,
): Promise<string> {
const defaults: PutFileOptions = {
encrypt: true,
sign: false,
contentType: ''
}
const opt = Object.assign({}, defaults, options)
let { contentType } = opt
if (!contentType) {
contentType = (typeof (content) === 'string') ? 'text/plain; charset=utf-8' : 'application/octet-stream'
}
if (!caller) {
caller = new UserSession()
}
// First, let's figure out if we need to get public/private keys,
// or if they were passed in
let privateKey = ''
let publicKey = ''
if (opt.sign) {
if (typeof (opt.sign) === 'string') {
privateKey = opt.sign
} else {
privateKey = caller.loadUserData().appPrivateKey
}
}
if (opt.encrypt) {
if (typeof (opt.encrypt) === 'string') {
publicKey = opt.encrypt
} else {
if (!privateKey) {
privateKey = caller.loadUserData().appPrivateKey
}
publicKey = getPublicKeyFromPrivate(privateKey)
}
}
// In the case of signing, but *not* encrypting,
// we perform two uploads. So the control-flow
// here will return there.
if (!opt.encrypt && opt.sign) {
const signatureObject = signECDSA(privateKey, content)
const signatureContent = JSON.stringify(signatureObject)
const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection()
try {
const fileUrls = await Promise.all([
uploadToGaiaHub(path, content, gaiaHubConfig, contentType),
uploadToGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`,
signatureContent, gaiaHubConfig, 'application/json')
])
return fileUrls[0]
} catch (error) {
const freshHubConfig = await caller.setLocalGaiaHubConnection()
const fileUrls = await Promise.all([
uploadToGaiaHub(path, content, freshHubConfig, contentType),
uploadToGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`,
signatureContent, freshHubConfig, 'application/json')
])
return fileUrls[0]
}
}
// In all other cases, we only need one upload.
if (opt.encrypt && !opt.sign) {
content = encryptContent(content, { publicKey })
contentType = 'application/json'
} else if (opt.encrypt && opt.sign) {
const cipherText = encryptContent(content, { publicKey })
const signatureObject = signECDSA(privateKey, cipherText)
const signedCipherObject = {
signature: signatureObject.signature,
publicKey: signatureObject.publicKey,
cipherText
}
content = JSON.stringify(signedCipherObject)
contentType = 'application/json'
}
const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection()
try {
return await uploadToGaiaHub(path, content, gaiaHubConfig, contentType)
} catch (error) {
const freshHubConfig = await caller.setLocalGaiaHubConnection()
const file = await uploadToGaiaHub(path, content, freshHubConfig, contentType)
return file
}
}
/**
* Deletes the specified file from the app's data store.
* @param path - The path to the file to delete.
* @param options - Optional options object.
* @param options.wasSigned - Set to true if the file was originally signed
* in order for the corresponding signature file to also be deleted.
* @returns Resolves when the file has been removed or rejects with an error.
*/
export async function deleteFile(
path: string,
options?: {
wasSigned?: boolean;
},
caller?: UserSession
) {
if (!caller) {
caller = new UserSession()
}
const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection()
const opts = Object.assign({}, options)
if (opts.wasSigned) {
// If signed, delete both the content file and the .sig file
try {
await deleteFromGaiaHub(path, gaiaHubConfig)
await deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig)
} catch (error) {
const freshHubConfig = await caller.setLocalGaiaHubConnection()
await deleteFromGaiaHub(path, freshHubConfig)
await deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig)
}
} else {
try {
await deleteFromGaiaHub(path, gaiaHubConfig)
} catch (error) {
const freshHubConfig = await caller.setLocalGaiaHubConnection()
await deleteFromGaiaHub(path, freshHubConfig)
}
}
}
/**
* Get the app storage bucket URL
* @param {String} gaiaHubUrl - the gaia hub URL
* @param {String} appPrivateKey - the app private key used to generate the app address
* @returns {Promise} That resolves to the URL of the app index file
* or rejects if it fails
*/
export function getAppBucketUrl(gaiaHubUrl: string, appPrivateKey: string) {
return getBucketUrl(gaiaHubUrl, appPrivateKey)
}
/**
* Loop over the list of files in a Gaia hub, and run a callback on each entry.
* Not meant to be called by external clients.
* @param {GaiaHubConfig} hubConfig - the Gaia hub config
* @param {String | null} page - the page ID
* @param {number} callCount - the loop count
* @param {number} fileCount - the number of files listed so far
* @param {function} callback - the callback to invoke on each file. If it returns a falsey
* value, then the loop stops. If it returns a truthy value, the loop continues.
* @returns {Promise} that resolves to the number of files listed.
* @private
* @ignore
*/
async function listFilesLoop(
caller: UserSession,
hubConfig: GaiaHubConfig | null,
page: string | null,
callCount: number,
fileCount: number,
callback: (name: string) => boolean
): Promise<number> {
if (callCount > 65536) {
// this is ridiculously huge, and probably indicates
// a faulty Gaia hub anyway (e.g. on that serves endless data)
throw new Error('Too many entries to list')
}
hubConfig = hubConfig || await caller.getOrSetLocalGaiaHubConnection()
let response: Response
try {
const pageRequest = JSON.stringify({ page })
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': `${pageRequest.length}`,
Authorization: `bearer ${hubConfig.token}`
},
body: pageRequest
}
response = await fetchPrivate(`${hubConfig.server}/list-files/${hubConfig.address}`, fetchOptions)
if (!response.ok) {
throw new Error(`listFiles failed with HTTP status ${response.status}`)
}
} catch (error) {
// If error occurs on the first call, perform a gaia re-connection and retry.
// Same logic as other gaia requests (putFile, getFile, etc).
if (callCount === 0) {
const freshHubConfig = await caller.setLocalGaiaHubConnection()
return listFilesLoop(caller, freshHubConfig, page, callCount + 1, 0, callback)
}
throw error
}
const responseText = await response.text()
const responseJSON = JSON.parse(responseText)
const entries = responseJSON.entries
const nextPage = responseJSON.page
if (entries === null || entries === undefined) {
// indicates a misbehaving Gaia hub or a misbehaving driver
// (i.e. the data is malformed)
throw new Error('Bad listFiles response: no entries')
}
for (let i = 0; i < entries.length; i++) {
const rc = callback(entries[i])
if (!rc) {
// callback indicates that we're done
return fileCount + i
}
}
if (nextPage && entries.length > 0) {
// keep going -- have more entries
return listFilesLoop(
caller, hubConfig, nextPage, callCount + 1, fileCount + entries.length, callback
)
} else {
// no more entries -- end of data
return fileCount + entries.length
}
}
/**
* List the set of files in this application's Gaia storage bucket.
* @param {function} callback - a callback to invoke on each named file that
* returns `true` to continue the listing operation or `false` to end it
* @return {Promise} that resolves to the number of files listed
*/
export function listFiles(
callback: (name: string) => boolean,
caller?: UserSession
): Promise<number> {
caller = caller || new UserSession()
return listFilesLoop(caller, null, null, 0, 0, callback)
}
export { connectToGaiaHub, uploadToGaiaHub, ALADIN_GAIA_HUB_LABEL }