import Foundation
/// Errors raised while building protocol identifiers.
public enum IdentifierError: Error, Sendable, Equatable {
case roomSecretTooShort(bytes: Int)
case malformedBase64URL
case malformedRoomId
case malformedPeerId
case malformedSeed
}
/// The invite secret of a room: at least 16 random bytes, carried as URL-safe
/// base64 (43 characters for the recommended 32 bytes). Everything about a
/// room — its relay id, its keys, its mailbox id — is derived from it, and it
/// is the only thing guarding who can join.
public struct RoomSecret: Sendable, Hashable {
public let bytes: Data
/// Wraps raw secret bytes (at least 16).
public init(bytes: Data) throws {
guard bytes.count >= 16 else { throw IdentifierError.roomSecretTooShort(bytes: bytes.count) }
self.bytes = bytes
}
/// Parses the URL-safe base64 form found in invite links.
public init(base64URL: String) throws {
guard let data = Bytes.fromBase64URL(base64URL) else { throw IdentifierError.malformedBase64URL }
try self.init(bytes: data)
}
/// 32 fresh random bytes.
public static func random() -> RoomSecret {
try! RoomSecret(bytes: Bytes.random(32))
}
/// URL-safe base64, unpadded (43 characters for 32 bytes).
public var base64URL: String { Bytes.base64URL(bytes) }
}
/// A relay room id: 43 URL-safe base64 characters (32 bytes), derived from the
/// room secret with HKDF. See `RoomCrypto`.
public struct RoomId: Sendable, Hashable, CustomStringConvertible {
public static let length = 43
public let string: String
public init(_ string: String) throws {
guard RoomId.isValid(string) else { throw IdentifierError.malformedRoomId }
self.string = string
}
init(unchecked string: String) { self.string = string }
public static func isValid(_ s: String) -> Bool {
s.utf8.count == length && s.utf8.allSatisfy { c in
(0x41...0x5A).contains(c) || (0x61...0x7A).contains(c) || (0x30...0x39).contains(c) || c == 0x2D || c == 0x5F
}
}
public var description: String { string }
}
/// A relay peer id: 16 bytes as 32 lowercase hex characters, self-chosen per
/// connection. Rejoining with the same id replaces the previous connection.
public struct PeerId: Sendable, Hashable, Comparable, CustomStringConvertible, Codable {
public static let length = 32
public let hex: String
public init(_ hex: String) throws {
guard PeerId.isValid(hex) else { throw IdentifierError.malformedPeerId }
self.hex = hex
}
public init(from decoder: any Decoder) throws {
let s = try decoder.singleValueContainer().decode(String.self)
try self.init(s)
}
public func encode(to encoder: any Encoder) throws {
var c = encoder.singleValueContainer()
try c.encode(hex)
}
/// 16 fresh random bytes.
public static func random() -> PeerId {
try! PeerId(Bytes.hex(Bytes.random(16)))
}
public static func isValid(_ s: String) -> Bool {
s.utf8.count == length && s.utf8.allSatisfy { c in (0x30...0x39).contains(c) || (0x61...0x66).contains(c) }
}
/// Lexicographic on the hex string; the lower peer offers in WebRTC negotiation.
public static func < (lhs: PeerId, rhs: PeerId) -> Bool { lhs.hex < rhs.hex }
public var description: String { hex }
}