PairingInvitation.kt

3.2 KB · raw

1package com.peachpasswords.sync23import java.util.Base644import kotlinx.serialization.Serializable5import kotlinx.serialization.encodeToString6import kotlinx.serialization.json.Json78private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"9private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")1011/**12 * Which side of the vault transfer the issuing device is on:13 *   POPULATED — issuer has the vault; the scanner receives it (historical default).14 *   EMPTY     — issuer has no vault; the scanner (phone) is the source and sends it.15 *16 * Older invitations from before this field existed parse as POPULATED, so a17 * scanner that doesn't know about `vaultStatus` still does the right thing:18 * it receives the vault from the source.19 */20enum class VaultStatus { EMPTY, POPULATED }2122data class PairingInvitation(23    val relayUrl: String,24    val token: String,25    val vaultStatus: VaultStatus = VaultStatus.POPULATED,26)2728@Serializable29private data class PairingInvitationWire(30    val relay: String,31    val token: String,32    val vault: String? = null,33)3435/**36 * QR/manual first-pair payload. Only reachability is present before Noise:37 * a credential-free wss relay and a fresh 128-bit token. [vaultStatus] tells38 * the scanner which side of the transfer it is on; older invitations omit it39 * and parse as POPULATED. Never add identity or vault fields here.40 */41fun createPairingInvitation(42    relayUrl: String,43    token: String = Rendezvous.newPairingToken(),44    vaultStatus: VaultStatus = VaultStatus.POPULATED,45): String {46    require(PairingTokenPattern.matches(token)) {47        "pairing invitation token must be 128-bit base64url"48    }49    val wire = PairingInvitationWire(50        relay = Rendezvous.normalizeRelayUrl(relayUrl),51        token = token,52        vault = wireVaultStatus(vaultStatus),53    )54    val encoded = Base64.getUrlEncoder().withoutPadding()55        .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))56    return PAIRING_INVITATION_PREFIX + encoded57}5859fun parsePairingInvitation(input: String): PairingInvitation? {60    val value = input.trim()61    if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null62    return runCatching {63        val decoded = Base64.getUrlDecoder().decode(64            value.removePrefix(PAIRING_INVITATION_PREFIX),65        )66        val wire = Json.decodeFromString<PairingInvitationWire>(67            String(decoded, Charsets.UTF_8),68        )69        require(PairingTokenPattern.matches(wire.token))70        PairingInvitation(71            relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),72            token = wire.token,73            vaultStatus = parseVaultStatus(wire.vault),74        )75    }.getOrNull()76}7778private fun wireVaultStatus(status: VaultStatus): String = when (status) {79    VaultStatus.EMPTY -> "empty"80    VaultStatus.POPULATED -> "populated"81}8283private fun parseVaultStatus(raw: String?): VaultStatus = when (raw) {84    "empty" -> VaultStatus.EMPTY85    "populated" -> VaultStatus.POPULATED86    // Missing or unknown defaults to POPULATED so historical invitations keep87    // working byte-for-byte: the scanner receives the vault from the source.88    else -> VaultStatus.POPULATED89}9091fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)