mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
9.1 KiB
Swift
236 lines
9.1 KiB
Swift
import CommonCrypto
|
|
import Foundation
|
|
|
|
public struct WebProxyConfiguration: Equatable, Hashable {
|
|
public static let port: UInt16 = 443
|
|
public static let maximumPathLength = 128
|
|
|
|
public let host: String
|
|
public let path: String
|
|
public let secret: Data
|
|
|
|
public init?(host: String, path: String = "", secret: Data) {
|
|
guard let host = Self.canonicalHost(host), let path = Self.canonicalPath(path), Self.isValidSecret(secret) else {
|
|
return nil
|
|
}
|
|
self.host = host
|
|
self.path = path
|
|
self.secret = secret
|
|
}
|
|
|
|
/// The marker BASE_PATH.md §3 puts in front of a base-path link's secret.
|
|
///
|
|
/// Deliberately not `0xDD`: a client without base-path support reads a 17-byte secret
|
|
/// beginning with `0xDD` as an ordinary padded secret and accepts the link, having
|
|
/// normalized `host/path` down to an empty host. With `0x70` it instead sees a secret
|
|
/// it cannot classify and reports an unsupported proxy type.
|
|
public static let secretMarker: UInt8 = 0x70
|
|
|
|
public static func isValidSecret(_ secret: Data) -> Bool {
|
|
return secret.count == 16 || (secret.count == 17 && secret.first == 0xdd)
|
|
}
|
|
|
|
/// Decodes a secret as written in a link or typed into the editor, reporting whether
|
|
/// it carried the `0x70` marker.
|
|
///
|
|
/// BASE_PATH.md §3's inverse rule - strip a leading `0x70` from a value of at least 17
|
|
/// bytes - is unambiguous because a canonical secret is 16 bytes, 17 beginning with
|
|
/// `0xDD`, or 21+ beginning with `0xEE`.
|
|
public static func parseMarkedSecret(_ value: String) -> (secret: Data, isMarked: Bool)? {
|
|
let value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// Hex first: a plain 32-character secret is also well-formed base64url, and hex is
|
|
// the canonical root form. A hex value that is not a secret still falls through, so
|
|
// a marked secret whose encoding happens to be all hex digits is not lost.
|
|
if let hex = self.hexBytes(value), let result = self.unmarkedSecret(hex) {
|
|
return result
|
|
}
|
|
guard let bytes = self.base64UrlBytes(value) else {
|
|
return nil
|
|
}
|
|
return self.unmarkedSecret(bytes)
|
|
}
|
|
|
|
public static func parseSecret(_ value: String) -> Data? {
|
|
return self.parseMarkedSecret(value)?.secret
|
|
}
|
|
|
|
/// The link form of `secret`: plain hex at the root, `0x70`-marked base64url under a
|
|
/// base path. A path-bearing link must use the marked form (BASE_PATH.md §3).
|
|
public static func linkSecretString(_ secret: Data, path: String) -> String {
|
|
if path.isEmpty {
|
|
return secret.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
return (Data([self.secretMarker]) + secret).webProxyBase64Url
|
|
}
|
|
|
|
private static func unmarkedSecret(_ bytes: Data) -> (secret: Data, isMarked: Bool)? {
|
|
let isMarked = bytes.count >= 17 && bytes.first == self.secretMarker
|
|
let secret = isMarked ? Data(bytes.dropFirst()) : bytes
|
|
guard self.isValidSecret(secret) else {
|
|
return nil
|
|
}
|
|
return (secret, isMarked)
|
|
}
|
|
|
|
private static func hexBytes(_ value: String) -> Data? {
|
|
guard !value.isEmpty, value.count % 2 == 0, value.allSatisfy({ $0.isHexDigit }) else {
|
|
return nil
|
|
}
|
|
var result = Data()
|
|
result.reserveCapacity(value.count / 2)
|
|
var index = value.startIndex
|
|
while index < value.endIndex {
|
|
let next = value.index(index, offsetBy: 2)
|
|
guard let byte = UInt8(value[index ..< next], radix: 16) else {
|
|
return nil
|
|
}
|
|
result.append(byte)
|
|
index = next
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func base64UrlBytes(_ value: String) -> Data? {
|
|
guard !value.isEmpty else {
|
|
return nil
|
|
}
|
|
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
let remainder = base64.count % 4
|
|
if remainder != 0 {
|
|
base64.append(String(repeating: "=", count: 4 - remainder))
|
|
}
|
|
return Data(base64Encoded: base64)
|
|
}
|
|
|
|
public static func canonicalHost(_ value: String) -> String? {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty,
|
|
!trimmed.contains("/"),
|
|
!trimmed.contains("@"),
|
|
!trimmed.contains(":"),
|
|
let components = URLComponents(string: "https://\(trimmed)"),
|
|
components.user == nil,
|
|
components.password == nil,
|
|
components.port == nil,
|
|
let host = components.url?.host?.lowercased(),
|
|
!host.isEmpty,
|
|
host.utf8.count <= 253 else {
|
|
return nil
|
|
}
|
|
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard labels.count >= 2,
|
|
labels.allSatisfy({ label in
|
|
!label.isEmpty && label.utf8.count <= 63 && label.first != "-" && label.last != "-" && label.allSatisfy { character in
|
|
character.isASCII && (character.isLetter || character.isNumber || character == "-")
|
|
}
|
|
}), !lastLabelIsNumeric(labels) else {
|
|
return nil
|
|
}
|
|
return host
|
|
}
|
|
|
|
public static func canonicalPath(_ value: String) -> String? {
|
|
var path = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if path.isEmpty {
|
|
return ""
|
|
}
|
|
guard !path.hasPrefix("/") else {
|
|
return nil
|
|
}
|
|
if path.hasSuffix("/") {
|
|
path.removeLast()
|
|
}
|
|
guard !path.isEmpty, path.count <= maximumPathLength else {
|
|
return nil
|
|
}
|
|
let segments = path.split(separator: "/", omittingEmptySubsequences: false)
|
|
guard segments.allSatisfy({ segment in
|
|
guard let first = segment.first, first.isASCII, first.isLetter || first.isNumber else {
|
|
return false
|
|
}
|
|
return segment.allSatisfy { character in
|
|
character.isASCII && (character.isLetter || character.isNumber || character == "-" || character == "_")
|
|
}
|
|
}) else {
|
|
return nil
|
|
}
|
|
return path
|
|
}
|
|
|
|
public static func canonicalAddress(_ value: String) -> (host: String, path: String)? {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let separator = trimmed.firstIndex(of: "/") else {
|
|
guard let host = canonicalHost(trimmed) else {
|
|
return nil
|
|
}
|
|
return (host, "")
|
|
}
|
|
guard let host = canonicalHost(String(trimmed[trimmed.startIndex ..< separator])),
|
|
let path = canonicalPath(String(trimmed[trimmed.index(after: separator)...])) else {
|
|
return nil
|
|
}
|
|
return (host, path)
|
|
}
|
|
|
|
private static func lastLabelIsNumeric(_ labels: [Substring]) -> Bool {
|
|
guard let label = labels.last, !label.isEmpty else {
|
|
return false
|
|
}
|
|
let hexadecimal = label.count >= 2 && label.first == "0" && (label[label.index(after: label.startIndex)] == "x" || label[label.index(after: label.startIndex)] == "X")
|
|
let digits = hexadecimal ? label.dropFirst(2) : label[...]
|
|
return digits.allSatisfy { character in
|
|
character.isASCII && (character.isNumber || (hexadecimal && character.isHexDigit))
|
|
}
|
|
}
|
|
|
|
public var address: String {
|
|
return self.path.isEmpty ? self.host : "\(self.host)/\(self.path)"
|
|
}
|
|
|
|
public var base: String {
|
|
return self.path.isEmpty ? "/" : "/\(self.path)/"
|
|
}
|
|
|
|
public var secretHex: String {
|
|
return self.secret.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
public func bridgeCapability() -> String {
|
|
let context: Data
|
|
if self.path.isEmpty {
|
|
context = Data("tdesktop-web-proxy-bridge-v1\n\(self.host)".utf8)
|
|
} else {
|
|
context = Data("tdesktop-web-proxy-bridge-v2\n\(self.host)\n\(self.path)".utf8)
|
|
}
|
|
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
self.secret.withUnsafeBytes { keyBytes in
|
|
context.withUnsafeBytes { contextBytes in
|
|
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), keyBytes.baseAddress, self.secret.count, contextBytes.baseAddress, context.count, &digest)
|
|
}
|
|
}
|
|
return Data(digest).webProxyBase64Url
|
|
}
|
|
|
|
public func bridgeURL(nonce: String) -> URL? {
|
|
guard nonce.utf8.count == 43 else {
|
|
return nil
|
|
}
|
|
var components = URLComponents()
|
|
components.scheme = "https"
|
|
components.host = self.host
|
|
components.path = self.base
|
|
components.queryItems = [URLQueryItem(name: "bridge", value: self.bridgeCapability())]
|
|
components.fragment = "android=\(nonce)"
|
|
return components.url
|
|
}
|
|
}
|
|
|
|
extension Data {
|
|
var webProxyBase64Url: String {
|
|
return self.base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
}
|