package com.peachpasswords.sync import java.util.Base64 import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:" private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$") /** * Which side of the vault transfer the issuing device is on: * POPULATED — issuer has the vault; the scanner receives it (historical default). * EMPTY — issuer has no vault; the scanner (phone) is the source and sends it. * * Older invitations from before this field existed parse as POPULATED, so a * scanner that doesn't know about `vaultStatus` still does the right thing: * it receives the vault from the source. */ enum class VaultStatus { EMPTY, POPULATED } data class PairingInvitation( val relayUrl: String, val token: String, val vaultStatus: VaultStatus = VaultStatus.POPULATED, ) @Serializable private data class PairingInvitationWire( val relay: String, val token: String, val vault: String? = null, ) /** * QR/manual first-pair payload. Only reachability is present before Noise: * a credential-free wss relay and a fresh 128-bit token. [vaultStatus] tells * the scanner which side of the transfer it is on; older invitations omit it * and parse as POPULATED. Never add identity or vault fields here. */ fun createPairingInvitation( relayUrl: String, token: String = Rendezvous.newPairingToken(), vaultStatus: VaultStatus = VaultStatus.POPULATED, ): String { require(PairingTokenPattern.matches(token)) { "pairing invitation token must be 128-bit base64url" } val wire = PairingInvitationWire( relay = Rendezvous.normalizeRelayUrl(relayUrl), token = token, vault = wireVaultStatus(vaultStatus), ) val encoded = Base64.getUrlEncoder().withoutPadding() .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8)) return PAIRING_INVITATION_PREFIX + encoded } fun parsePairingInvitation(input: String): PairingInvitation? { val value = input.trim() if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null return runCatching { val decoded = Base64.getUrlDecoder().decode( value.removePrefix(PAIRING_INVITATION_PREFIX), ) val wire = Json.decodeFromString( String(decoded, Charsets.UTF_8), ) require(PairingTokenPattern.matches(wire.token)) PairingInvitation( relayUrl = Rendezvous.normalizeRelayUrl(wire.relay), token = wire.token, vaultStatus = parseVaultStatus(wire.vault), ) }.getOrNull() } private fun wireVaultStatus(status: VaultStatus): String = when (status) { VaultStatus.EMPTY -> "empty" VaultStatus.POPULATED -> "populated" } private fun parseVaultStatus(raw: String?): VaultStatus = when (raw) { "empty" -> VaultStatus.EMPTY "populated" -> VaultStatus.POPULATED // Missing or unknown defaults to POPULATED so historical invitations keep // working byte-for-byte: the scanner receives the vault from the source. else -> VaultStatus.POPULATED } fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)