PairingInvitation.kt

3.6 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}$")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 }2223data class PairingInvitation(24    val relayUrl: String,25    val token: String,26    val vaultStatus: VaultStatus = VaultStatus.POPULATED,27    val noiseStaticPublicKeyHex: String? = null,28)2930@Serializable31private data class PairingInvitationWire(32    val relay: String,33    val token: String,34    val vault: String? = null,35    val static: String? = null,36)3738/**39 * QR/manual first-pair payload. It carries relay reachability, a fresh 128-bit40 * token, the transfer direction, and the issuer's Noise static public key so41 * the scanner can authenticate the cleartext ACK before the handshake.42 */43fun createPairingInvitation(44    relayUrl: String,45    token: String = Rendezvous.newPairingToken(),46    vaultStatus: VaultStatus = VaultStatus.POPULATED,47    noiseStaticPublicKeyHex: String? = null,48): String {49    require(PairingTokenPattern.matches(token)) {50        "pairing invitation token must be 128-bit base64url"51    }52    require(noiseStaticPublicKeyHex == null || NoiseStaticPattern.matches(noiseStaticPublicKeyHex)) {53        "invalid Noise static public key"54    }55    val wire = PairingInvitationWire(56        relay = Rendezvous.normalizeRelayUrl(relayUrl),57        token = token,58        vault = wireVaultStatus(vaultStatus),59        static = noiseStaticPublicKeyHex,60    )61    val encoded = Base64.getUrlEncoder().withoutPadding()62        .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))63    return PAIRING_INVITATION_PREFIX + encoded64}6566fun parsePairingInvitation(input: String): PairingInvitation? {67    val value = input.trim()68    if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null69    return runCatching {70        val decoded = Base64.getUrlDecoder().decode(71            value.removePrefix(PAIRING_INVITATION_PREFIX),72        )73        val wire = Json.decodeFromString<PairingInvitationWire>(74            String(decoded, Charsets.UTF_8),75        )76        require(PairingTokenPattern.matches(wire.token))77        require(wire.static == null || NoiseStaticPattern.matches(wire.static))78        PairingInvitation(79            relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),80            token = wire.token,81            vaultStatus = parseVaultStatus(wire.vault),82            noiseStaticPublicKeyHex = wire.static,83        )84    }.getOrNull()85}8687private fun wireVaultStatus(status: VaultStatus): String = when (status) {88    VaultStatus.EMPTY -> "empty"89    VaultStatus.POPULATED -> "populated"90}9192private fun parseVaultStatus(raw: String?): VaultStatus = when (raw) {93    "empty" -> VaultStatus.EMPTY94    "populated" -> VaultStatus.POPULATED95    // Missing or unknown defaults to POPULATED so historical invitations keep96    // working byte-for-byte: the scanner receives the vault from the source.97    else -> VaultStatus.POPULATED98}99100fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)