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}$")1112131415161718192021enum 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)37383940414243fun 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 96 97 else -> VaultStatus.POPULATED98}99100fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)