fission-suite/webnative

View on GitHub
src/components/capabilities/implementation/fission-lobby.ts

Summary

Maintainability
B
5 hrs
Test Coverage
import * as Uint8arrays from "uint8arrays"

import * as Base64 from "../../../common/base64.js"
import * as Capabilities from "../../../capabilities.js"
import * as Crypto from "../../../components/crypto/implementation.js"
import * as DID from "../../../did/index.js"
import * as Fission from "../../../common/fission.js"
import * as Path from "../../../path/index.js"
import * as TypeChecks from "../../../common/type-checks.js"
import * as Ucan from "../../../ucan/index.js"

import { Implementation, RequestOptions } from "../implementation.js"
import { Maybe } from "../../../common/types.js"
import { VERSION } from "../../../common/version.js"


// 🧩


export type Dependencies = {
  crypto: Crypto.Implementation
}



// 🛠


export async function collect(
  endpoints: Fission.Endpoints,
  dependencies: Dependencies
): Promise<Maybe<Capabilities.Capabilities>> {
  const url = new URL(self.location.href)
  const username = url.searchParams.get("username") ?? ""
  if (!username) return null

  const info = await retry(
    () => getClassifiedViaPostMessage(endpoints, dependencies.crypto),
    {
      tries: 20,
      timeout: 60000,
      timeoutMessage: "Trying to retrieve UCAN(s) and readKey(s) from the auth lobby timed out after 60 seconds."
    }
  )

  const secrets = await translateClassifiedInfo(dependencies, info)

  if (!secrets) {
    throw new Error("Failed to retrieve secrets from lobby url parameters")
  }

  url.searchParams.delete("authorised")
  url.searchParams.delete("cancelled")
  url.searchParams.delete("newUser")
  url.searchParams.delete("username")

  history.replaceState(null, document.title, url.toString())

  return { ...secrets, username }
}


/**
 * Redirects to a lobby.
 *
 * NOTE: Only works on the main thread, as it uses `window.location`.
 */
export async function request(
  endpoints: Fission.Endpoints,
  dependencies: Dependencies,
  options: RequestOptions = {}
): Promise<void> {
  const { permissions } = options

  const app = permissions?.app
  const fs = permissions?.fs
  const platform = permissions?.platform
  const raw = permissions?.raw
  const sharing = permissions?.sharing

  const exchangeDid = await DID.exchange(dependencies.crypto)
  const writeDid = await DID.write(dependencies.crypto)
  const sharedRepo = false
  const redirectTo = options.returnUrl || window.location.href

  // Compile params
  const params = [
    ["didExchange", exchangeDid],
    ["didWrite", writeDid],
    ["redirectTo", redirectTo],
    ["sdk", VERSION.toString()],
    ["sharedRepo", sharedRepo ? "t" : "f"],
    ["sharing", sharing ? "t" : "f"]

  ].concat(
    app ? [["appFolder", `${app.creator}/${app.name}`]] : [],
    fs?.private ? fs.private.map(p => ["privatePath", Path.toPosix(p, { absolute: true })]) : [],
    fs?.public ? fs.public.map(p => ["publicPath", Path.toPosix(p, { absolute: true })]) : [],
    raw ? [["raw", Base64.urlEncode(JSON.stringify(raw))]] : [],
    options.extraParams ? Object.entries(options.extraParams) : []

  ).concat((() => {
    const apps = platform?.apps

    switch (typeof apps) {
      case "string": return [["app", apps]]
      case "object": return apps.map(a => ["app", a])
      default: return []
    }

  })())

  // And, go!
  window.location.href = endpoints.lobby + "?" +
    params
      .map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
      .join("&")
}



// COLLECTION HELPERS


type LobbyClassifiedInfo = {
  sessionKey: string
  secrets: string
  iv: string
}

type LobbySecrets = {
  fs: Record<string, { key: string; bareNameFilter: string }>
  ucans: string[]
}

async function getClassifiedViaPostMessage(
  endpoints: Fission.Endpoints,
  crypto: Crypto.Implementation
): Promise<LobbyClassifiedInfo> {
  const didExchange = await DID.exchange(crypto)
  const iframe: HTMLIFrameElement = await new Promise(resolve => {
    const iframe = document.createElement("iframe")
    iframe.id = "odd-secret-exchange"
    iframe.style.width = "0"
    iframe.style.height = "0"
    iframe.style.border = "none"
    iframe.style.display = "none"
    document.body.appendChild(iframe)

    iframe.onload = () => {
      resolve(iframe)
    }

    iframe.src = `${endpoints.lobby}/exchange.html`
  })

  return new Promise((resolve, reject) => {
    function stop() {
      globalThis.removeEventListener("message", listen)
      document.body.removeChild(iframe)
      reject()
    }

    function listen(event: MessageEvent<string>) {
      if (new URL(event.origin).host !== new URL(endpoints.lobby).host) return stop()
      if (event.data == null) return stop()

      let classifiedInfo

      try {
        classifiedInfo = JSON.parse(event.data)
      } catch {
        stop()
      }

      if (!isLobbyClassifiedInfo(classifiedInfo)) stop()
      globalThis.removeEventListener("message", listen)

      try { document.body.removeChild(iframe) } catch { }

      resolve(classifiedInfo)
    }

    globalThis.addEventListener("message", listen)

    if (iframe.contentWindow == null) {
      throw new Error("Can't import UCANs & readKey(s): No access to its contentWindow")
    }

    const message = {
      odd: "exchange-secrets",
      didExchange
    }

    iframe.contentWindow.postMessage(message, iframe.src)
  })
}

function isLobbyClassifiedInfo(obj: unknown): obj is LobbyClassifiedInfo {
  return TypeChecks.isObject(obj)
    && TypeChecks.isString(obj.sessionKey)
    && TypeChecks.isString(obj.secrets)
    && TypeChecks.isString(obj.iv)
}

function isLobbySecrets(obj: unknown): obj is LobbySecrets {
  return TypeChecks.isObject(obj)
    && TypeChecks.isObject(obj.fs)
    && Object.values(obj.fs).every(a => TypeChecks.hasProp(a, "key") && TypeChecks.hasProp(a, "bareNameFilter"))
    && Array.isArray(obj.ucans)
    && obj.ucans.every(a => TypeChecks.isString(a))
}

async function translateClassifiedInfo(
  { crypto }: Dependencies,
  classifiedInfo: LobbyClassifiedInfo
): Promise<{ fileSystemSecrets: Capabilities.FileSystemSecret[]; ucans: Ucan.Ucan[] }> {
  // Extract session key
  const rawSessionKey = await crypto.keystore.decrypt(
    Uint8arrays.fromString(classifiedInfo.sessionKey, "base64pad")
  )

  // The encrypted session key and read keys can be encoded in both UTF-16 and UTF-8.
  // This is because keystore-idb uses UTF-16 by default, and that's what the ODD SDK used before.
  // ---
  // This easy way of detection works because the decrypted session key is encoded in base 64.
  // That means it'll only ever use the first byte to encode it, and if it were UTF-16 it would
  // split up the two bytes. Hence we check for the second byte here.
  const isUtf16 = rawSessionKey[1] === 0

  const sessionKey = isUtf16
    ? Uint8arrays.fromString(
      new TextDecoder("utf-16").decode(rawSessionKey),
      "base64pad"
    )
    : rawSessionKey

  // Decrypt secrets
  const secretsStr = await crypto.aes.decrypt(
    Uint8arrays.fromString(classifiedInfo.secrets, "base64pad"),
    sessionKey,
    Crypto.SymmAlg.AES_GCM,
    Uint8arrays.fromString(classifiedInfo.iv, "base64pad")
  )

  const secrets: unknown = JSON.parse(
    Uint8arrays.toString(secretsStr, "utf8")
  )

  if (!isLobbySecrets(secrets)) throw new Error("Invalid secrets received")

  const fileSystemSecrets: Capabilities.FileSystemSecret[] =
    isLobbySecrets(secrets)
      ? Object
        .entries(secrets.fs)
        .map(([posixPath, { bareNameFilter, key }]) => {
          return {
            bareNameFilter: bareNameFilter,
            path: Path.fromPosix(posixPath),
            readKey: Uint8arrays.fromString(key, "base64pad")
          }
        })
      : []

  const ucans: Ucan.Ucan[] = secrets.ucans.map(
    (u: string) => Ucan.decode(u)
  )

  return {
    fileSystemSecrets,
    ucans,
  }
}



// HELPERS


async function retry<T>(
  action: () => Promise<T>,
  options: { tries: number; timeout: number; timeoutMessage: string }
): Promise<T> {
  return new Promise((resolve, reject) => {
    if (options.tries > 0) {
      const unoMas = () => {
        retry(action, { ...options, tries: options.tries - 1 })
      }

      const timeoutId = setTimeout(unoMas, options.timeout)

      action()
        .then(resolve, unoMas)
        .finally(() => clearTimeout(timeoutId))

    } else {
      reject(new Error(options.timeoutMessage))

    }
  })
}



// 🛳


export function implementation(
  endpoints: Fission.Endpoints,
  dependencies: Dependencies
): Implementation {
  return {
    collect: () => collect(endpoints, dependencies),
    request: (...args) => request(endpoints, dependencies, ...args)
  }
}