OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / set-password.js

58 lines · javascript ref:
// applySetPassword — place a new login password hash on the RIGHT account in a config.
//
// THE BUG THIS FIXES. `hpp-onboard --set-password` did `cfg.appPasswordHash = hashPassword(pw)`,
// writing the TOP LEVEL of config.json. That is correct for a legacy single-DID config and wrong for
// every multi-DID one — and all three production hosts are multi-DID. `bootMultiDevice` spreads the
// per-DID entry into the device (`bootSwarmDevice({ storage, ...entry })`, device-node.js:694), so
// `sessions` is built from `entry.appPasswordHash`. A top-level field is never read by anything.
//
// So the command printed "password updated", exited 0, and changed NOTHING: the old password kept
// working and the new one never did. A silent no-op that reports success is the worst shape of bug —
// it is believed, and the next person debugs the login instead of the tool.
//
// Pure: takes the already-computed hash and mutates/returns the config object. No I/O, no scrypt, so
// the behaviour is testable without a keystore or a running daemon.

// Decide WHICH account gets the hash, and refuse rather than guess when it is ambiguous.
// Returns { mode, did, handle, warning? }. Throws with an actionable message on ambiguity.
export function applySetPassword (cfg, { did = null, hash } = {}) {
  if (!cfg || typeof cfg !== 'object') throw new Error('applySetPassword: config object required')
  if (typeof hash !== 'string' || !hash) throw new Error('applySetPassword: hash required')

  const dids = Array.isArray(cfg.dids) ? cfg.dids : null

  // MULTI-DID: the per-DID entry is the only thing read at boot.
  if (dids && dids.length) {
    if (!did && dids.length > 1) {
      throw new Error(
        'this is a MULTI-DID config with ' + dids.length + ' accounts — name one with --did <did>.\n' +
        'hosted: ' + dids.map((d) => (d && d.did) + (d && d.handle ? ' (' + d.handle + ')' : '')).join(', ')
      )
    }
    const target = did ? dids.find((d) => d && d.did === did) : dids[0]
    if (!target) {
      throw new Error(did + ' is not a hosted DID in this config.\n' +
        'hosted: ' + dids.map((d) => d && d.did).join(', '))
    }
    target.appPasswordHash = hash
    const out = { mode: 'multi', did: target.did, handle: target.handle || null }
    // A DID with no jwtSecret cannot mint sessions at all, so a password on it does nothing on its own.
    // That is the deliberate shape of a co-owned community feed (nobody logs in AS a feed), so this
    // warns rather than blocks — a two-step setup that adds the secret next is legitimate.
    if (!target.jwtSecret) {
      out.warning = 'this DID has no jwtSecret, so it cannot mint sessions — the password alone will not ' +
        'enable login. If it is a community feed, it is meant to have neither.'
    }
    return out
  }

  // LEGACY single-DID: the top level IS the account.
  if (did && cfg.did && did !== cfg.did) {
    throw new Error('--did ' + did + ' does not match this single-DID config (' + cfg.did + ')')
  }
  cfg.appPasswordHash = hash
  const out = { mode: 'single', did: cfg.did || null, handle: cfg.handle || null }
  if (!cfg.jwtSecret) out.warning = 'this config has no jwtSecret, so it cannot mint sessions'
  return out
}