fission-suite/webnative

View on GitHub
src/linking/consumer.ts

Summary

Maintainability
F
3 days
Test Coverage
import * as Uint8arrays from "uint8arrays"

import * as Auth from "../components/auth/implementation.js"
import * as Crypto from "../components/crypto/implementation.js"
import * as Manners from "../components/manners/implementation.js"

import * as Check from "../common/type-checks.js"
import * as DID from "../did/index.js"
import * as Linking from "./common.js"
import * as Ucan from "../ucan/index.js"

import { Components } from "../components.js"
import { EventEmitter, EventListener } from "../common/event-emitter.js"
import { LinkingError, LinkingStep, LinkingWarning, tryParseMessage } from "./common.js"

import type { Maybe, Result } from "../common/index.js"


export type AccountLinkingConsumer = {
  on: <K extends keyof ConsumerEventMap>(eventName: K, listener: EventListener<ConsumerEventMap[ K ]>) => void
  cancel: () => void
}

export interface ConsumerEventMap {
  "challenge": { pin: number[] }
  "link": { approved: boolean; username: string }
  "done": undefined
}

export type Dependencies = {
  auth: Auth.Implementation<Components>
  crypto: Crypto.Implementation
  manners: Manners.Implementation
}

type LinkingState = {
  username: Maybe<string>
  sessionKey: Maybe<Uint8Array>
  temporaryRsaPair: Maybe<CryptoKeyPair>
  step: Maybe<LinkingStep>
}

/**
 * Create an account linking consumer
 *
 * @param options consumer options
 * @param options.username username of the account
 * @returns an account linking event emitter and cancel function
 */
export const createConsumer = async (
  dependencies: Dependencies,
  options: { username: string }
): Promise<AccountLinkingConsumer> => {
  const { username } = options
  const handleLinkingError = (errorOrWarning: LinkingError | LinkingWarning) => Linking.handleLinkingError(dependencies.manners, errorOrWarning)

  let eventEmitter: Maybe<EventEmitter<ConsumerEventMap>> = new EventEmitter()

  const ls: LinkingState = {
    username,
    sessionKey: null,
    temporaryRsaPair: null,
    step: LinkingStep.Broadcast
  }

  const handleMessage = async (event: MessageEvent): Promise<void> => {
    const { data } = event
    const message = data.arrayBuffer ? new TextDecoder().decode(await data.arrayBuffer()) : data

    switch (ls.step) {

      // Broadcast
      // ---------
      case LinkingStep.Broadcast:
        return handleLinkingError(new LinkingWarning("Consumer is not ready to start linking"))

      // Negotiation
      // -----------
      case LinkingStep.Negotiation:
        if (ls.sessionKey) {
          handleLinkingError(new LinkingWarning("Consumer already received a session key"))
        } else if (!ls.temporaryRsaPair || !ls.temporaryRsaPair.privateKey) {
          handleLinkingError(new LinkingError("Consumer missing RSA key pair when handling session key message"))
        } else {
          const sessionKeyResult = await handleSessionKey(
            dependencies.crypto,
            ls.temporaryRsaPair.privateKey,
            message
          )

          if (sessionKeyResult.ok) {
            ls.sessionKey = sessionKeyResult.value

            const { pin, challenge } = await generateUserChallenge(dependencies.crypto, ls.sessionKey)
            channel.send(challenge)
            eventEmitter?.emit("challenge", { pin: Array.from(pin) })
            ls.step = LinkingStep.Delegation
          } else {
            handleLinkingError(sessionKeyResult.error)
          }
        }

        break

      // Delegation
      // ----------
      case LinkingStep.Delegation:
        if (!ls.sessionKey) {
          handleLinkingError(new LinkingError("Consumer was missing session key when linking device"))
        } else if (!ls.username) {
          handleLinkingError(new LinkingError("Consumer was missing username when linking device"))
        } else {
          const linkingResult = await linkDevice(
            dependencies.auth,
            dependencies.crypto,
            ls.sessionKey,
            ls.username,
            message
          )

          if (linkingResult.ok) {
            const { approved } = linkingResult.value
            eventEmitter?.emit("link", { approved, username: ls.username })
            await done()
          } else {
            handleLinkingError(linkingResult.error)
          }
        }

        break

    }
  }

  const done = async () => {
    eventEmitter?.emit("done", undefined)
    eventEmitter = null
    channel.close()
    clearInterval(rsaExchangeInterval)
  }

  const channel = await dependencies.auth.createChannel({ handleMessage, username })

  const rsaExchangeInterval = setInterval(async () => {
    if (!ls.sessionKey) {
      const { temporaryRsaPair, temporaryDID } = await generateTemporaryExchangeKey(dependencies.crypto)
      ls.temporaryRsaPair = temporaryRsaPair
      ls.step = LinkingStep.Negotiation

      channel.send(temporaryDID)
    } else {
      clearInterval(rsaExchangeInterval)
    }
  }, 2000)

  return {
    on: (...args) => eventEmitter?.on(...args),
    cancel: done
  }
}


// 🔗 Device Linking Steps

/**
 *  BROADCAST
 *
 * Generate a temporary RSA keypair and extract a temporary DID from it.
 * The temporary DID will be broadcast on the channel to start the linking process.
 *
 * @returns temporary RSA key pair and temporary DID
 */
export const generateTemporaryExchangeKey = async (
  crypto: Crypto.Implementation
): Promise<{ temporaryRsaPair: CryptoKeyPair; temporaryDID: string }> => {
  const temporaryRsaPair = await crypto.rsa.genKey()
  const pubKey = await crypto.rsa.exportPublicKey(temporaryRsaPair.publicKey)

  const temporaryDID = DID.publicKeyToDid(crypto, pubKey, "rsa")
  return { temporaryRsaPair, temporaryDID }
}

/**
 *  NEGOTIATION
 *
 * Decrypt the session key and check the closed UCAN for capability.
 * The session key is encrypted with the temporary RSA keypair.
 * The closed UCAN is encrypted with the session key.
 *
 * @param temporaryRsaPrivateKey
 * @param data
 * @returns AES session key
 */
export const handleSessionKey = async (
  crypto: Crypto.Implementation,
  temporaryRsaPrivateKey: CryptoKey,
  data: string
): Promise<Result<Uint8Array, Error>> => {
  const typeGuard = (message: unknown): message is { iv: string; msg: string; sessionKey: string } => {
    return Check.isObject(message)
      && "iv" in message && typeof message.iv === "string"
      && "msg" in message && typeof message.msg === "string"
      && "sessionKey" in message && typeof message.sessionKey === "string"
  }

  const parseResult = tryParseMessage(data, typeGuard, { participant: "Consumer", callSite: "handleSessionKey" })

  if (parseResult.ok) {
    const { iv: encodedIV, msg, sessionKey: encodedSessionKey } = parseResult.value
    const iv = Uint8arrays.fromString(encodedIV, "base64pad")

    let sessionKey
    try {
      const encryptedSessionKey = Uint8arrays.fromString(encodedSessionKey, "base64pad")
      sessionKey = await crypto.rsa.decrypt(encryptedSessionKey, temporaryRsaPrivateKey)
    } catch {
      return { ok: false, error: new LinkingWarning(`Consumer received a session key in handleSessionKey that it could not decrypt: ${data}. Ignoring message`) }
    }

    let encodedUcan = null
    try {
      encodedUcan = await crypto.aes.decrypt(
        Uint8arrays.fromString(msg, "base64pad"),
        sessionKey,
        Crypto.SymmAlg.AES_GCM,
        iv
      )
    } catch {
      return { ok: false, error: new LinkingError("Consumer could not decrypt closed UCAN with provided session key.") }
    }

    const decodedUcan = Ucan.decode(
      Uint8arrays.toString(encodedUcan, "utf8")
    )

    if (await Ucan.isValid(crypto, decodedUcan) === false) {
      return { ok: false, error: new LinkingError("Consumer received an invalid closed UCAN") }
    }

    if (decodedUcan.payload.ptc) {
      return { ok: false, error: new LinkingError("Consumer received a closed UCAN with potency. Closed UCAN must not have potency.") }
    }

    const sessionKeyFromFact = decodedUcan.payload.fct[ 0 ] && decodedUcan.payload.fct[ 0 ].sessionKey
    if (!sessionKeyFromFact) {
      return { ok: false, error: new LinkingError("Consumer received a closed UCAN that was missing a session key in facts.") }
    }

    const sessionKeyWeAlreadyGot = Uint8arrays.toString(sessionKey, "base64pad")
    if (sessionKeyFromFact !== sessionKeyWeAlreadyGot) {
      return { ok: false, error: new LinkingError("Consumer received a closed UCAN session key does not match the session key") }
    }

    return { ok: true, value: sessionKey }
  } else {
    return parseResult
  }
}


/**
 * NEGOTIATION
 *
 * Generate pin and challenge message for verification by the producer.
 *
 * @param sessionKey
 * @returns pin and challenge message
 */
export const generateUserChallenge = async (
  crypto: Crypto.Implementation,
  sessionKey: Uint8Array
): Promise<{ pin: number[]; challenge: string }> => {
  const pin = Array.from(crypto.misc.randomNumbers({ amount: 6 })).map(n => n % 9)
  const iv = crypto.misc.randomNumbers({ amount: 16 })

  const msg = await crypto.aes.encrypt(
    Uint8arrays.fromString(
      JSON.stringify({
        did: await DID.ucan(crypto),
        pin
      }),
      "utf8"
    ),
    sessionKey,
    Crypto.SymmAlg.AES_GCM,
    iv
  )

  const challenge = JSON.stringify({
    iv: Uint8arrays.toString(iv, "base64pad"),
    msg: Uint8arrays.toString(msg, "base64pad")
  })

  return { pin, challenge }
}

/**
 * DELEGATION
 *
 * Decrypt the delegated credentials and forward to the dependency injected linkDevice function,
 * or report that delegation was declined.
 *
 * @param sessionKey
 * @param username
 * @param data
 * @returns linking result
 */
export const linkDevice = async (
  auth: Auth.Implementation<Components>,
  crypto: Crypto.Implementation,
  sessionKey: Uint8Array,
  username: string,
  data: string
): Promise<Result<{ approved: boolean }, Error>> => {
  const typeGuard = (message: unknown): message is { iv: string; msg: string } => {
    return Check.isObject(message)
      && "iv" in message && typeof message.iv === "string"
      && "msg" in message && typeof message.msg === "string"
  }

  const parseResult = tryParseMessage(data, typeGuard, { participant: "Consumer", callSite: "linkDevice" })

  if (parseResult.ok) {
    const { iv: encodedIV, msg } = parseResult.value
    const iv = Uint8arrays.fromString(encodedIV, "base64")

    let message = null
    try {
      message = await crypto.aes.decrypt(
        Uint8arrays.fromString(msg, "base64pad"),
        sessionKey,
        Crypto.SymmAlg.AES_GCM,
        iv
      )
    } catch {
      return { ok: false, error: new LinkingWarning("Consumer ignoring message that could not be decrypted in linkDevice.") }
    }

    const response = tryParseMessage(
      Uint8arrays.toString(message, "utf8"),
      Check.isObject,
      { participant: "Consumer", callSite: "linkDevice" }
    )

    if (!response.ok) {
      return response
    }

    if (response?.value.linkStatus === "DENIED") {
      return { ok: true, value: { approved: false } }
    }

    await auth.linkDevice(username, response.value)

    return { ok: true, value: { approved: true } }
  } else {
    return parseResult
  }
}