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}$")1112131415161718192021enum class VaultStatus { EMPTY, POPULATED }22232425262728293031data 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)45464748495051fun 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 104 105 else -> VaultStatus.POPULATED106}107108fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)