OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / publisher-trust.js

129 lines · javascript ref:
/**
 * Does this release actually come from the identity that owns the name?
 *
 * The release registry proves a release was signed by the device key it names
 * (lib/release-registry.js). It says nothing about whether that device is
 * allowed to publish under this app NAME — the registry and the name registry
 * are separate structures, and a signature only binds an entry to whatever key
 * happens to be in it.
 *
 * The missing link is the NameRegistry claim, which records the publisher's
 * identityRoot. This module joins the two: resolve the entry's device key back
 * to its root via the attestation chain, then require that root to be the one
 * the claim is anchored to.
 *
 * That is why the anchor had to be the ROOT and not a device key (org #142) —
 * bound to a device, this check would refuse every release published from any
 * other device the identity owns, and would have to be re-pointed on every
 * device rotation.
 *
 * TRANSITIONAL, deliberately (org #144 step 3):
 *
 *   - Entries published before `publisherChain` existed carry no chain.
 *   - Claims made before #142 are anchored to a device key, not a root.
 *   - An app installed by pgh:// URL may have no name claim at all.
 *
 * So a missing piece is `indeterminate`, not a refusal. Only a present-and-
 * contradictory pair is refused. Enforcement — treating indeterminate as a
 * refusal — is a later flip, once publishers have re-published and the
 * existing claims have been re-anchored with `pgh names update`.
 */

import IdentityKey from 'keet-identity-key'
import b4a from 'b4a'

export const TRUST_OK = 'ok'
export const TRUST_INDETERMINATE = 'indeterminate'
export const TRUST_REFUSED = 'refused'

/**
 * @param {object} entry  release entry ({ publisherDevicePubkey, publisherChain })
 * @param {object} claim  NameRegistry claim ({ publisher: { identityRoot } })
 * @returns {{ status: string, reason: string, root?: string }}
 */
export function checkPublisherTrust (entry, claim) {
  if (!entry) return { status: TRUST_INDETERMINATE, reason: 'no release entry' }

  const claimedRoot = claim && claim.publisher && claim.publisher.identityRoot
  if (!claimedRoot) {
    return { status: TRUST_INDETERMINATE, reason: 'name is not claimed, so there is no root to check against' }
  }

  const device = entry.publisherDevicePubkey
  if (!device) {
    return { status: TRUST_INDETERMINATE, reason: 'entry names no publisher device key' }
  }

  // A claim still anchored to a device key (pre-#142) cannot answer the
  // question — matching it would only prove "same device", which is the
  // property we are deliberately not depending on.
  if (claimedRoot === device && !entry.publisherChain) {
    return {
      status: TRUST_INDETERMINATE,
      reason: 'claim is anchored to a device key, not a root (pre-#142 claim; re-anchor with `pgh names update`)'
    }
  }

  if (!entry.publisherChain) {
    return {
      status: TRUST_INDETERMINATE,
      reason: 'entry carries no attestation chain (published before signing was wired)'
    }
  }

  let verified
  try {
    const proof = b4a.from(entry.publisherChain, 'base64')
    if (!proof || proof.length === 0) {
      return { status: TRUST_REFUSED, reason: 'attestation chain is empty' }
    }
    verified = IdentityKey.verify(proof)
  } catch (err) {
    return { status: TRUST_REFUSED, reason: 'attestation chain did not parse: ' + err.message }
  }
  if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) {
    return { status: TRUST_REFUSED, reason: 'attestation chain failed verification' }
  }

  // The chain must describe THIS entry's device, or it proves nothing about
  // this release — it could be any valid chain lifted from anywhere.
  const chainDevice = b4a.toString(verified.devicePublicKey, 'hex')
  if (chainDevice !== device) {
    return {
      status: TRUST_REFUSED,
      reason: 'chain attests device ' + chainDevice.slice(0, 12) + '… but the entry was signed by ' +
        String(device).slice(0, 12) + '…'
    }
  }

  const root = b4a.toString(verified.identityPublicKey, 'hex')
  if (root !== claimedRoot) {
    return {
      status: TRUST_REFUSED,
      reason: 'release publisher resolves to identity ' + root.slice(0, 12) +
        '… but the name is owned by ' + String(claimedRoot).slice(0, 12) + '…',
      root
    }
  }

  return { status: TRUST_OK, reason: 'publisher device chains to the identity that owns the name', root }
}

/**
 * Throwing form for install paths. Refuses only a contradiction; an
 * indeterminate result passes and is returned so the caller can report it.
 */
export function assertPublisherTrust (entry, claim, { name } = {}) {
  const result = checkPublisherTrust(entry, claim)
  if (result.status === TRUST_REFUSED) {
    throw Object.assign(
      new Error(
        'refusing ' + (name || (entry && entry.name)) + '@' + (entry && entry.version) +
        ': ' + result.reason
      ),
      { code: 'EPUBLISHERMISMATCH', result }
    )
  }
  return result
}