PairingInvitation.kt

4.1 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-v2:"9private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")10private val NoiseStaticPattern = Regex("^[0-9a-f]{64}$")1112/**13 * Which side of the vault transfer the issuing device is on:14 *   POPULATED — issuer has the vault; the scanner receives it (historical default).15 *   EMPTY     — issuer has no vault; the scanner (phone) is the source and sends it.16 *17 * Older invitations from before this field existed parse as POPULATED, so a18 * scanner that doesn't know about `vaultStatus` still does the right thing:19 * it receives the vault from the source.20 */21enum class VaultStatus { EMPTY, POPULATED }2223/**24 * PB-26-103: `static` is mandatory in practice — both adoption directions now25 * refuse an invitation that pins no Noise static, in either direction of the26 * transfer. That is a behaviour change under the v1 prefix rather than a format27 * change, so the prefix moves to v2: a mismatched pair fails at the scan with an28 * unrecognised-invitation error instead of mid-handshake with a pin error.29 * Both clients must be updated together; there is no interop with v1.30 */31data class PairingInvitation(32    val relayUrl: String,33    val token: String,34    val vaultStatus: VaultStatus = VaultStatus.POPULATED,35    val noiseStaticPublicKeyHex: String? = null,36)3738@Serializable39private data class PairingInvitationWire(40    val relay: String,41    val token: String,42    val vault: String? = null,43    val static: String? = null,44)4546/**47 * QR/manual first-pair payload. It carries relay reachability, a fresh 128-bit48 * token, the transfer direction, and the issuer's Noise static public key so49 * the scanner can authenticate the cleartext ACK before the handshake.50 */51fun createPairingInvitation(52    relayUrl: String,53    token: String = Rendezvous.newPairingToken(),54    vaultStatus: VaultStatus = VaultStatus.POPULATED,55    noiseStaticPublicKeyHex: String? = null,56): String {57    require(PairingTokenPattern.matches(token)) {58        "pairing invitation token must be 128-bit base64url"59    }60    require(noiseStaticPublicKeyHex == null || NoiseStaticPattern.matches(noiseStaticPublicKeyHex)) {61        "invalid Noise static public key"62    }63    val wire = PairingInvitationWire(64        relay = Rendezvous.normalizeRelayUrl(relayUrl),65        token = token,66        vault = wireVaultStatus(vaultStatus),67        static = noiseStaticPublicKeyHex,68    )69    val encoded = Base64.getUrlEncoder().withoutPadding()70        .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))71    return PAIRING_INVITATION_PREFIX + encoded72}7374fun parsePairingInvitation(input: String): PairingInvitation? {75    val value = input.trim()76    if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null77    return runCatching {78        val decoded = Base64.getUrlDecoder().decode(79            value.removePrefix(PAIRING_INVITATION_PREFIX),80        )81        val wire = Json.decodeFromString<PairingInvitationWire>(82            String(decoded, Charsets.UTF_8),83        )84        require(PairingTokenPattern.matches(wire.token))85        require(wire.static == null || NoiseStaticPattern.matches(wire.static))86        PairingInvitation(87            relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),88            token = wire.token,89            vaultStatus = parseVaultStatus(wire.vault),90            noiseStaticPublicKeyHex = wire.static,91        )92    }.getOrNull()93}9495private fun wireVaultStatus(status: VaultStatus): String = when (status) {96    VaultStatus.EMPTY -> "empty"97    VaultStatus.POPULATED -> "populated"98}99100private fun parseVaultStatus(raw: String?): VaultStatus = when (raw) {101    "empty" -> VaultStatus.EMPTY102    "populated" -> VaultStatus.POPULATED103    // Missing or unknown defaults to POPULATED so historical invitations keep104    // working byte-for-byte: the scanner receives the vault from the source.105    else -> VaultStatus.POPULATED106}107108fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)