OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / outboxlog / blind-seal.js

485 lines · javascript ref:
import sodium from 'sodium-universal'
import b4a from 'b4a'
import {
  OUTBOXLOG_BLIND_SEAL_DEFAULT_ALG,
  OUTBOXLOG_BLIND_SEAL_VERSION,
  createOutboxBlindSealedBody,
  isOutboxBlindSealedBody,
  verifyOutboxRecordSignature
} from './outbox-log.js'

export const OUTBOXLOG_BLIND_SEAL_KEY_BYTES = sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES
export const OUTBOXLOG_BLIND_SEAL_NONCE_BYTES = sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
export const OUTBOXLOG_BLIND_SEAL_TAG_BYTES = sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES
export const OUTBOXLOG_BLIND_SEAL_AAD_VERSION = 1
export const OUTBOXLOG_BLIND_SEAL_AAD_DOMAIN = 'outboxlog.blind.payload'
export const OUTBOXLOG_BLIND_KEY_ENVELOPE_VERSION = 1
export const OUTBOXLOG_BLIND_KEY_ENVELOPE_ALG = 'crypto_box_seal'
export const OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_VERSION = 1
export const OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_DOMAIN = 'outboxlog.blind.recipient'
export const OUTBOXLOG_BLIND_RECIPIENT_PUBLIC_KEY_BYTES = sodium.crypto_box_PUBLICKEYBYTES
export const OUTBOXLOG_BLIND_RECIPIENT_SECRET_KEY_BYTES = sodium.crypto_box_SECRETKEYBYTES
export const OUTBOXLOG_BLIND_KEY_ENVELOPE_SEAL_BYTES = sodium.crypto_box_SEALBYTES

export function createOutboxBlindSealKey () {
  const key = b4a.alloc(OUTBOXLOG_BLIND_SEAL_KEY_BYTES)
  sodium.randombytes_buf(key)
  return key
}

export function createOutboxBlindRecipientKeyPair ({ seed = null } = {}) {
  const publicKey = b4a.alloc(OUTBOXLOG_BLIND_RECIPIENT_PUBLIC_KEY_BYTES)
  const secretKey = b4a.alloc(OUTBOXLOG_BLIND_RECIPIENT_SECRET_KEY_BYTES)
  if (seed == null) sodium.crypto_box_keypair(publicKey, secretKey)
  else sodium.crypto_box_seed_keypair(publicKey, secretKey, normalizeSeed(seed))
  return {
    publicKey,
    secretKey,
    publicKeyHex: encodeOutboxBlindRecipientPublicKey(publicKey),
    secretKeyHex: encodeOutboxBlindRecipientSecretKey(secretKey)
  }
}

export function createOutboxBlindSealAAD ({
  namespace,
  appId,
  type,
  id,
  keyId = null,
  version = OUTBOXLOG_BLIND_SEAL_AAD_VERSION,
  domain = OUTBOXLOG_BLIND_SEAL_AAD_DOMAIN
} = {}) {
  if (version !== OUTBOXLOG_BLIND_SEAL_AAD_VERSION) throw new Error('OutboxLogBlindSeal: unsupported aad version')
  if (domain !== OUTBOXLOG_BLIND_SEAL_AAD_DOMAIN) throw new Error('OutboxLogBlindSeal: unsupported aad domain')
  return JSON.stringify({
    domain,
    version,
    namespace: normalizeAadField(namespace, 'namespace', 64),
    appId: normalizeAadField(appId, 'appId', 128),
    type: normalizeAadField(type, 'type', 64),
    id: normalizeAadField(id, 'id', 256),
    keyId: keyId == null ? null : normalizeAadField(keyId, 'keyId', 256)
  })
}

export function sealOutboxBlindPayload (payload, {
  key,
  nonce = null,
  aad = null,
  keyId = null,
  alg = OUTBOXLOG_BLIND_SEAL_DEFAULT_ALG
} = {}) {
  if (alg !== OUTBOXLOG_BLIND_SEAL_DEFAULT_ALG) throw new Error('OutboxLogBlindSeal: unsupported algorithm')
  const keyBuf = normalizeBlindSealKey(key)
  const nonceBuf = nonce == null ? randomNonce() : normalizeBlindSealNonce(nonce)
  const plaintext = encodePayload(payload)
  const additionalData = normalizeAad(aad)
  const ciphertext = b4a.alloc(plaintext.byteLength + OUTBOXLOG_BLIND_SEAL_TAG_BYTES)

  sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
    ciphertext,
    plaintext,
    additionalData,
    null,
    nonceBuf,
    keyBuf
  )

  return createOutboxBlindSealedBody({
    alg,
    nonce: encodeBase64Url(nonceBuf),
    ciphertext: encodeBase64Url(ciphertext),
    keyId
  })
}

export function openOutboxBlindPayload (body, { key, aad = null } = {}) {
  if (!isOutboxBlindSealedBody(body)) throw new Error('OutboxLogBlindSeal: invalid sealed body')
  const sealed = body.sealed
  if (sealed.version !== OUTBOXLOG_BLIND_SEAL_VERSION) throw new Error('OutboxLogBlindSeal: unsupported sealed body version')
  if (sealed.alg !== OUTBOXLOG_BLIND_SEAL_DEFAULT_ALG) throw new Error('OutboxLogBlindSeal: unsupported algorithm')

  const keyBuf = normalizeBlindSealKey(key)
  const nonce = decodeBase64Url(sealed.nonce, 'nonce')
  const ciphertext = decodeBase64Url(sealed.ciphertext, 'ciphertext')
  if (nonce.byteLength !== OUTBOXLOG_BLIND_SEAL_NONCE_BYTES) throw new Error('OutboxLogBlindSeal: bad nonce')
  if (ciphertext.byteLength < OUTBOXLOG_BLIND_SEAL_TAG_BYTES) throw new Error('OutboxLogBlindSeal: bad ciphertext')

  const plaintext = b4a.alloc(ciphertext.byteLength - OUTBOXLOG_BLIND_SEAL_TAG_BYTES)
  try {
    sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
      plaintext,
      null,
      ciphertext,
      normalizeAad(aad),
      nonce,
      keyBuf
    )
  } catch {
    throw new Error('OutboxLogBlindSeal: decrypt failed')
  }

  return decodePayload(plaintext)
}

export function wrapOutboxBlindSealKey (key, recipients, {
  version = OUTBOXLOG_BLIND_KEY_ENVELOPE_VERSION,
  alg = OUTBOXLOG_BLIND_KEY_ENVELOPE_ALG
} = {}) {
  if (version !== OUTBOXLOG_BLIND_KEY_ENVELOPE_VERSION) throw new Error('OutboxLogBlindSeal: unsupported key envelope version')
  if (alg !== OUTBOXLOG_BLIND_KEY_ENVELOPE_ALG) throw new Error('OutboxLogBlindSeal: unsupported key envelope algorithm')
  const keyBuf = normalizeBlindSealKey(key)
  const list = normalizeRecipients(recipients)
  if (list.length === 0) throw new Error('OutboxLogBlindSeal: at least one recipient required')

  return {
    version,
    alg,
    recipients: list.map((recipient) => {
      const sealedKey = b4a.alloc(keyBuf.byteLength + OUTBOXLOG_BLIND_KEY_ENVELOPE_SEAL_BYTES)
      sodium.crypto_box_seal(sealedKey, keyBuf, recipient.publicKey)
      return {
        id: recipient.id,
        publicKey: encodeOutboxBlindRecipientPublicKey(recipient.publicKey),
        sealedKey: encodeBase64Url(sealedKey)
      }
    })
  }
}

export function openOutboxBlindSealKeyEnvelope (envelope, {
  publicKey,
  secretKey,
  recipientId = null
} = {}) {
  const publicKeyBuf = normalizeRecipientPublicKey(publicKey)
  const secretKeyBuf = normalizeRecipientSecretKey(secretKey)
  const entry = selectRecipientEnvelope(envelope, publicKeyBuf, recipientId)
  const sealedKey = decodeBase64Url(entry.sealedKey, 'sealedKey')
  if (sealedKey.byteLength !== OUTBOXLOG_BLIND_SEAL_KEY_BYTES + OUTBOXLOG_BLIND_KEY_ENVELOPE_SEAL_BYTES) {
    throw new Error('OutboxLogBlindSeal: bad sealed key')
  }

  const key = b4a.alloc(OUTBOXLOG_BLIND_SEAL_KEY_BYTES)
  let opened = false
  try {
    opened = sodium.crypto_box_seal_open(key, sealedKey, publicKeyBuf, secretKeyBuf)
  } catch {
    throw new Error('OutboxLogBlindSeal: key unwrap failed')
  }
  if (opened === false) throw new Error('OutboxLogBlindSeal: key unwrap failed')
  return key
}

export function createOutboxBlindRecipientDirectoryEntry ({
  id,
  publicKey,
  namespace,
  appId = null,
  keyId,
  replacesKeyId = null,
  version = OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_VERSION,
  domain = OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_DOMAIN
} = {}) {
  if (version !== OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_VERSION) throw new Error('OutboxLogBlindSeal: unsupported recipient directory version')
  if (domain !== OUTBOXLOG_BLIND_RECIPIENT_DIRECTORY_DOMAIN) throw new Error('OutboxLogBlindSeal: unsupported recipient directory domain')
  const entry = {
    domain,
    version,
    namespace: normalizeDirectoryField(namespace, 'namespace', 64),
    appId: appId == null ? null : normalizeDirectoryField(appId, 'appId', 128),
    id: normalizeDirectoryField(id, 'id', 256),
    keyId: normalizeDirectoryField(keyId, 'keyId', 256),
    replacesKeyId: replacesKeyId == null ? null : normalizeDirectoryField(replacesKeyId, 'replacesKeyId', 256),
    publicKey: encodeOutboxBlindRecipientPublicKey(publicKey)
  }
  return {
    ...entry,
    fingerprint: fingerprintRecipientDirectoryEntry(entry)
  }
}

export function verifyOutboxBlindRecipientDirectoryEntry (entry, {
  namespace = null,
  appId = null,
  id = null,
  keyId = null,
  publicKey = null
} = {}) {
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
    throw new Error('OutboxLogBlindSeal: invalid recipient directory entry')
  }
  const normalized = createOutboxBlindRecipientDirectoryEntry(entry)
  if (entry.fingerprint !== normalized.fingerprint) throw new Error('OutboxLogBlindSeal: recipient directory fingerprint mismatch')
  if (namespace != null && normalized.namespace !== String(namespace)) throw new Error('OutboxLogBlindSeal: recipient directory namespace mismatch')
  if (appId != null && normalized.appId !== String(appId)) throw new Error('OutboxLogBlindSeal: recipient directory appId mismatch')
  if (id != null && normalized.id !== String(id)) throw new Error('OutboxLogBlindSeal: recipient directory id mismatch')
  if (keyId != null && normalized.keyId !== String(keyId)) throw new Error('OutboxLogBlindSeal: recipient directory keyId mismatch')
  if (publicKey != null && normalized.publicKey !== encodeOutboxBlindRecipientPublicKey(publicKey)) {
    throw new Error('OutboxLogBlindSeal: recipient directory public key mismatch')
  }
  return normalized
}

export function verifyOutboxBlindRecipientDirectoryRotation (nextEntry, previousEntry, opts = {}) {
  const previous = verifyOutboxBlindRecipientDirectoryEntry(previousEntry, opts)
  const next = verifyOutboxBlindRecipientDirectoryEntry(nextEntry, opts)
  if (next.namespace !== previous.namespace) throw new Error('OutboxLogBlindSeal: recipient directory rotation namespace mismatch')
  if (next.appId !== previous.appId) throw new Error('OutboxLogBlindSeal: recipient directory rotation appId mismatch')
  if (next.id !== previous.id) throw new Error('OutboxLogBlindSeal: recipient directory rotation id mismatch')
  if (next.replacesKeyId !== previous.keyId) throw new Error('OutboxLogBlindSeal: recipient directory rotation replacesKeyId mismatch')
  if (next.keyId === previous.keyId) throw new Error('OutboxLogBlindSeal: recipient directory rotation keyId unchanged')
  if (next.publicKey === previous.publicKey) throw new Error('OutboxLogBlindSeal: recipient directory rotation public key unchanged')
  return next
}

export function verifyOutboxBlindRecipientDirectoryRecord (record, {
  trustedPublishers,
  publisherId = null,
  type = 'recipient',
  namespace = null,
  namespaces = null,
  recipientField = 'recipient',
  directoryEntry = null,
  entryOpts = {}
} = {}) {
  if (!record || typeof record !== 'object' || Array.isArray(record)) {
    throw new Error('OutboxLogBlindSeal: invalid recipient directory record')
  }

  const publisher = normalizePublisherPublicKey(publisherId || record._k || record.author, 'recipient directory publisher')
  const trusted = normalizeTrustedPublishers(trustedPublishers)
  if (!trusted.has(publisher)) throw new Error('OutboxLogBlindSeal: untrusted recipient directory publisher')
  if (record._k != null && normalizePublisherPublicKey(record._k, 'recipient directory record _k') !== publisher) {
    throw new Error('OutboxLogBlindSeal: recipient directory publisher mismatch')
  }
  if (record.author != null && normalizePublisherPublicKey(record.author, 'recipient directory record author') !== publisher) {
    throw new Error('OutboxLogBlindSeal: recipient directory publisher mismatch')
  }

  const expectedNamespace = namespace == null ? null : String(namespace)
  if (expectedNamespace != null && record._ns !== expectedNamespace) {
    throw new Error('OutboxLogBlindSeal: recipient directory record namespace mismatch')
  }
  const verifierNamespace = expectedNamespace || (typeof record._ns === 'string' ? record._ns : undefined)
  if (!verifyOutboxRecordSignature({
    appId: publisher,
    type,
    data: record
  }, {
    namespace: verifierNamespace,
    namespaces
  })) {
    throw new Error('OutboxLogBlindSeal: recipient directory record signature mismatch')
  }

  const entry = directoryEntry == null ? record[recipientField] : directoryEntry
  return {
    publisherId: publisher,
    recipient: verifyOutboxBlindRecipientDirectoryEntry(entry, entryOpts)
  }
}

export function encodeOutboxBlindSealKey (key) {
  return b4a.toString(normalizeBlindSealKey(key), 'hex')
}

export function decodeOutboxBlindSealKey (key) {
  return normalizeBlindSealKey(key)
}

export function encodeOutboxBlindRecipientPublicKey (publicKey) {
  return b4a.toString(normalizeRecipientPublicKey(publicKey), 'hex')
}

export function decodeOutboxBlindRecipientPublicKey (publicKey) {
  return normalizeRecipientPublicKey(publicKey)
}

export function encodeOutboxBlindRecipientSecretKey (secretKey) {
  return b4a.toString(normalizeRecipientSecretKey(secretKey), 'hex')
}

export function decodeOutboxBlindRecipientSecretKey (secretKey) {
  return normalizeRecipientSecretKey(secretKey)
}

function randomNonce () {
  const nonce = b4a.alloc(OUTBOXLOG_BLIND_SEAL_NONCE_BYTES)
  sodium.randombytes_buf(nonce)
  return nonce
}

function normalizeBlindSealKey (key) {
  if (typeof key === 'string') {
    if (!/^[0-9a-f]{64}$/i.test(key)) throw new Error('OutboxLogBlindSeal: key must be 32 bytes')
    key = b4a.from(key, 'hex')
  } else if (key && typeof key.byteLength === 'number') {
    key = b4a.from(key)
  }
  if (!b4a.isBuffer(key) || key.byteLength !== OUTBOXLOG_BLIND_SEAL_KEY_BYTES) {
    throw new Error('OutboxLogBlindSeal: key must be 32 bytes')
  }
  return key
}

function normalizeRecipientPublicKey (publicKey) {
  return normalizeHexOrBuffer(publicKey, OUTBOXLOG_BLIND_RECIPIENT_PUBLIC_KEY_BYTES, 'recipient public key')
}

function normalizeRecipientSecretKey (secretKey) {
  return normalizeHexOrBuffer(secretKey, OUTBOXLOG_BLIND_RECIPIENT_SECRET_KEY_BYTES, 'recipient secret key')
}

function normalizeSeed (seed) {
  return normalizeHexOrBuffer(seed, sodium.crypto_box_SEEDBYTES || OUTBOXLOG_BLIND_RECIPIENT_SECRET_KEY_BYTES, 'recipient seed')
}

function normalizeHexOrBuffer (value, bytes, label) {
  if (typeof value === 'string') {
    if (!new RegExp('^[0-9a-f]{' + (bytes * 2) + '}$', 'i').test(value)) {
      throw new Error('OutboxLogBlindSeal: ' + label + ' must be ' + bytes + ' bytes')
    }
    value = b4a.from(value, 'hex')
  } else if (value && typeof value.byteLength === 'number') {
    value = b4a.from(value)
  }
  if (!b4a.isBuffer(value) || value.byteLength !== bytes) {
    throw new Error('OutboxLogBlindSeal: ' + label + ' must be ' + bytes + ' bytes')
  }
  return value
}

function normalizeRecipients (recipients) {
  const input = Array.isArray(recipients) ? recipients : [recipients]
  return input
    .filter(Boolean)
    .map((recipient) => {
      if (typeof recipient === 'string' || (recipient && typeof recipient.byteLength === 'number')) {
        const publicKey = normalizeRecipientPublicKey(recipient)
        return {
          id: encodeOutboxBlindRecipientPublicKey(publicKey),
          publicKey
        }
      }
      if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) {
        throw new Error('OutboxLogBlindSeal: bad recipient')
      }
      const publicKey = normalizeRecipientPublicKey(recipient.publicKey || recipient.publicKeyHex)
      const id = recipient.id == null ? encodeOutboxBlindRecipientPublicKey(publicKey) : String(recipient.id)
      if (!id || id.length > 256) throw new Error('OutboxLogBlindSeal: bad recipient id')
      return { id, publicKey }
    })
}

function selectRecipientEnvelope (envelope, publicKey, recipientId) {
  if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
    throw new Error('OutboxLogBlindSeal: invalid key envelope')
  }
  if (envelope.version !== OUTBOXLOG_BLIND_KEY_ENVELOPE_VERSION) throw new Error('OutboxLogBlindSeal: unsupported key envelope version')
  if (envelope.alg !== OUTBOXLOG_BLIND_KEY_ENVELOPE_ALG) throw new Error('OutboxLogBlindSeal: unsupported key envelope algorithm')
  if (!Array.isArray(envelope.recipients)) throw new Error('OutboxLogBlindSeal: invalid key envelope recipients')
  const publicKeyHex = encodeOutboxBlindRecipientPublicKey(publicKey)
  const entry = envelope.recipients.find((recipient) => {
    if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) return false
    if (recipientId != null) return recipient.id === recipientId
    return recipient.publicKey === publicKeyHex
  })
  if (!entry) throw new Error('OutboxLogBlindSeal: recipient not found')
  if (entry.publicKey !== publicKeyHex) throw new Error('OutboxLogBlindSeal: recipient public key mismatch')
  if (typeof entry.sealedKey !== 'string') throw new Error('OutboxLogBlindSeal: bad sealed key')
  return entry
}

function normalizeBlindSealNonce (nonce) {
  if (typeof nonce === 'string') nonce = decodeBase64Url(nonce, 'nonce')
  else if (nonce && typeof nonce.byteLength === 'number') nonce = b4a.from(nonce)
  if (!b4a.isBuffer(nonce) || nonce.byteLength !== OUTBOXLOG_BLIND_SEAL_NONCE_BYTES) {
    throw new Error('OutboxLogBlindSeal: nonce must be 24 bytes')
  }
  return nonce
}

function normalizeAad (aad) {
  if (aad == null) return null
  if (typeof aad === 'string') return b4a.from(aad, 'utf8')
  if (aad && typeof aad.byteLength === 'number') return b4a.from(aad)
  throw new Error('OutboxLogBlindSeal: aad must be a string or buffer')
}

function normalizeAadField (value, label, maxLength) {
  if (value == null) throw new Error('OutboxLogBlindSeal: aad ' + label + ' required')
  value = String(value)
  if (!value || value.length > maxLength) throw new Error('OutboxLogBlindSeal: bad aad ' + label)
  return value
}

function normalizeDirectoryField (value, label, maxLength) {
  if (value == null) throw new Error('OutboxLogBlindSeal: recipient directory ' + label + ' required')
  value = String(value)
  if (!value || value.length > maxLength) throw new Error('OutboxLogBlindSeal: bad recipient directory ' + label)
  return value
}

function normalizePublisherPublicKey (value, label) {
  if (value && typeof value === 'object' && !Array.isArray(value)) {
    value = value.publicKey || value.publicKeyHex || value.id
  }
  if (typeof value === 'string') {
    if (!/^[0-9a-f]{64}$/i.test(value)) throw new Error('OutboxLogBlindSeal: ' + label + ' must be 32-byte public key')
    return value.toLowerCase()
  }
  if (value && typeof value.byteLength === 'number') {
    const buffer = b4a.from(value)
    if (buffer.byteLength !== 32) throw new Error('OutboxLogBlindSeal: ' + label + ' must be 32-byte public key')
    return b4a.toString(buffer, 'hex')
  }
  throw new Error('OutboxLogBlindSeal: ' + label + ' must be 32-byte public key')
}

function normalizeTrustedPublishers (trustedPublishers) {
  const input = trustedPublishers instanceof Set
    ? [...trustedPublishers]
    : (Array.isArray(trustedPublishers) ? trustedPublishers : null)
  if (!input || input.length === 0) throw new Error('OutboxLogBlindSeal: recipient directory trusted publishers required')
  return new Set(input.map((publisher) => normalizePublisherPublicKey(publisher, 'trusted recipient directory publisher')))
}

function fingerprintRecipientDirectoryEntry (entry) {
  const out = b4a.alloc(32)
  sodium.crypto_generichash(out, b4a.from(JSON.stringify(entry), 'utf8'))
  return b4a.toString(out, 'hex')
}

function encodePayload (payload) {
  let text
  try {
    text = JSON.stringify(payload)
  } catch {
    throw new Error('OutboxLogBlindSeal: payload must be JSON serializable')
  }
  if (typeof text !== 'string') throw new Error('OutboxLogBlindSeal: payload must be JSON serializable')
  return b4a.from(text, 'utf8')
}

function decodePayload (plaintext) {
  try {
    return JSON.parse(b4a.toString(plaintext, 'utf8'))
  } catch {
    throw new Error('OutboxLogBlindSeal: payload decode failed')
  }
}

function encodeBase64Url (buffer) {
  return Buffer.from(buffer).toString('base64url')
}

function decodeBase64Url (value, label) {
  if (typeof value !== 'string' || value.length === 0) throw new Error('OutboxLogBlindSeal: bad ' + label)
  try {
    return b4a.from(Buffer.from(value, 'base64url'))
  } catch {
    throw new Error('OutboxLogBlindSeal: bad ' + label)
  }
}