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-v2:" private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$") private val NoiseStaticPattern = Regex("^[0-9a-f]{64}$") /** * 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 } /** * PB-26-103: `static` is mandatory in practice — both adoption directions now * refuse an invitation that pins no Noise static, in either direction of the * transfer. That is a behaviour change under the v1 prefix rather than a format * change, so the prefix moves to v2: a mismatched pair fails at the scan with an * unrecognised-invitation error instead of mid-handshake with a pin error. * Both clients must be updated together; there is no interop with v1. */ data class PairingInvitation( val relayUrl: String, val token: String, val vaultStatus: VaultStatus = VaultStatus.POPULATED, val noiseStaticPublicKeyHex: String? = null, ) @Serializable private data class PairingInvitationWire( val relay: String, val token: String, val vault: String? = null, val static: String? = null, ) /** * QR/manual first-pair payload. It carries relay reachability, a fresh 128-bit * token, the transfer direction, and the issuer's Noise static public key so * the scanner can authenticate the cleartext ACK before the handshake. */ fun createPairingInvitation( relayUrl: String, token: String = Rendezvous.newPairingToken(), vaultStatus: VaultStatus = VaultStatus.POPULATED, noiseStaticPublicKeyHex: String? = null, ): String { require(PairingTokenPattern.matches(token)) { "pairing invitation token must be 128-bit base64url" } require(noiseStaticPublicKeyHex == null || NoiseStaticPattern.matches(noiseStaticPublicKeyHex)) { "invalid Noise static public key" } val wire = PairingInvitationWire( relay = Rendezvous.normalizeRelayUrl(relayUrl), token = token, vault = wireVaultStatus(vaultStatus), static = noiseStaticPublicKeyHex, ) 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)) require(wire.static == null || NoiseStaticPattern.matches(wire.static)) PairingInvitation( relayUrl = Rendezvous.normalizeRelayUrl(wire.relay), token = wire.token, vaultStatus = parseVaultStatus(wire.vault), noiseStaticPublicKeyHex = wire.static, ) }.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)