Auditoria
As regras do PIN
Kit/Sources/EscaliburKeys/PINPolicy.swift
As regras do PIN: a forma, a escada de atraso e o relógio que sobrevive a reinicio sem trancar o dono por dias.
| 1 | import Darwin |
| 2 | import Foundation |
| 3 | |
| 4 | /// As regras do PIN: a forma, a escada de atraso e o relogio que sobrevive a |
| 5 | /// reinicio sem trancar o dono por dias. |
| 6 | public enum PINPolicy { |
| 7 | /// O tamanho padrao, e o de todo PIN gravado antes de existir a escolha. |
| 8 | public static let digits = 6 |
| 9 | |
| 10 | /// Os tamanhos que o dono pode escolher (pedido do dono, 16/10/2026: 4 tambem). Com 4, |
| 11 | /// sao 10^4 combinacoes em vez de 10^6: contra quem tem execucao de codigo no |
| 12 | /// aparelho, minutos em vez de horas (docs/seguranca.md §2.8). |
| 13 | public static let lengths = [4, 6] |
| 14 | |
| 15 | /// O PIN e do dono: qualquer combinacao de 4 ou 6 digitos vale, inclusive 1111 e |
| 16 | /// 123456. Decisao do dono em 2026-09-26, que retirou a lista de PINs faceis. So a |
| 17 | /// forma e conferida: 4 ou 6 digitos de 0 a 9. Contra quem tenta pela interface |
| 18 | /// ficam a escada de atraso e, se o dono ligar, o apagar apos erros. |
| 19 | public static func isWellFormed(_ digits: [UInt8]) -> Bool { |
| 20 | lengths.contains(digits.count) && digits.allSatisfy { $0 <= 9 } |
| 21 | } |
| 22 | |
| 23 | /// A escada de atraso, em segundos, depois de `failures` erros seguidos. |
| 24 | /// |
| 25 | /// Do nono erro em diante a espera dobra a cada erro, de 3 horas ate 7 dias (auditoria |
| 26 | /// 4, C7-04). Com o teto antigo de 3 horas, quem tem o iPhone tentava cerca de 2.900 |
| 27 | /// PINs por ano; com o dobro, 18 em 30 dias e 66 em um ano. |
| 28 | public static func delay(afterFailures failures: UInt32) -> TimeInterval { |
| 29 | switch failures { |
| 30 | case 0...2: return 0 |
| 31 | case 3: return 5 |
| 32 | case 4: return 15 |
| 33 | case 5: return 60 |
| 34 | case 6: return 5 * 60 |
| 35 | case 7: return 15 * 60 |
| 36 | case 8: return 60 * 60 |
| 37 | default: |
| 38 | let doubled = 3 * 60 * 60 * pow(2, Double(min(failures - 9, 16))) |
| 39 | return min(doubled, maxDelay) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// O teto da escada: 7 dias por tentativa. |
| 44 | public static let maxDelay: TimeInterval = 7 * 24 * 60 * 60 |
| 45 | |
| 46 | /// A espera do A1 depois que o Face ID cai sem o dono pedir (o cadastro de rostos mudou, |
| 47 | /// o Face ID foi tirado do app ou do iPhone): durante ela, ver as palavras, lacrar o |
| 48 | /// envelope e remover carteira ficam travados. Quem viu o codigo do iPhone e o PIN |
| 49 | /// cadastra o proprio rosto e derruba o slot; sem a espera, isso trocava "PIN e rosto" |
| 50 | /// por "so o PIN" na hora (auditoria 4, A1). |
| 51 | public static let biometryLossHold: TimeInterval = 24 * 60 * 60 |
| 52 | |
| 53 | /// Quantos erros podem apagar tudo, a escolha do dono (ou nunca). |
| 54 | public static let wipeOptions: [UInt32] = [5, 10, 15, 20] |
| 55 | |
| 56 | // MARK: Relogios |
| 57 | |
| 58 | /// Tempo desde o boot, contando o tempo dormindo, e que nao anda para tras quando |
| 59 | /// alguem muda o relogio. O relogio de parede nunca entra na conta da espera: |
| 60 | /// adiantar nao encurta, atrasar nao tranca o dono por um ano. |
| 61 | public static var uptime: TimeInterval { |
| 62 | var time = timespec() |
| 63 | clock_gettime(CLOCK_MONOTONIC_RAW, &time) |
| 64 | return TimeInterval(time.tv_sec) + TimeInterval(time.tv_nsec) / 1e9 |
| 65 | } |
| 66 | |
| 67 | /// Identidade deste boot, 16 bytes. Se mudou, o relogio monotonico recomecou do zero |
| 68 | /// e o prazo gravado com ele deixou de valer. |
| 69 | /// |
| 70 | /// No Mac e no simulador e o `kern.bootsessionuuid`, comparado byte a byte. No iPhone |
| 71 | /// a sandbox recusa essa leitura (EPERM, medido num iPhone 13 Pro Max com iOS 26.3): |
| 72 | /// vale o instante do boot (`kern.boottime`), marcado com `boottimeMark` e comparado |
| 73 | /// com folga de `boottimeTolerance` em `isSameBoot`, porque ele oscila um pouco com o |
| 74 | /// ajuste de hora pela rede. |
| 75 | public static var bootSession: [UInt8] { |
| 76 | if let uuid = bootSessionUUID { return withUnsafeBytes(of: uuid.uuid) { Array($0) } } |
| 77 | var time = timeval() |
| 78 | var size = MemoryLayout<timeval>.size |
| 79 | guard sysctlbyname("kern.boottime", &time, &size, nil, 0) == 0, time.tv_sec > 0 else { return unknownBoot } |
| 80 | let micros = UInt64(time.tv_sec) * 1_000_000 + UInt64(time.tv_usec) |
| 81 | return boottimeMark + (0..<8).map { UInt8(truncatingIfNeeded: micros >> (56 - 8 * $0)) } |
| 82 | } |
| 83 | |
| 84 | private static var bootSessionUUID: UUID? { |
| 85 | var size = 0 |
| 86 | guard sysctlbyname("kern.bootsessionuuid", nil, &size, nil, 0) == 0, size > 0 else { return nil } |
| 87 | var buffer = [CChar](repeating: 0, count: size) |
| 88 | guard sysctlbyname("kern.bootsessionuuid", &buffer, &size, nil, 0) == 0 else { return nil } |
| 89 | return UUID(uuidString: String(decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)) |
| 90 | } |
| 91 | |
| 92 | /// Os 8 primeiros bytes de uma identidade tirada do `kern.boottime` ("ESCBOOT1"). |
| 93 | static let boottimeMark: [UInt8] = Array("ESCBOOT1".utf8) |
| 94 | |
| 95 | /// Reiniciar move o instante do boot pelo tempo que o iPhone ficou ligado mais o da |
| 96 | /// propria reinicializacao, sempre bem mais que isto. O ajuste de hora pela rede |
| 97 | /// move milissegundos. Mudar a hora a mao parece um boot novo: o PIN volta a ser |
| 98 | /// pedido e a espera recomeca cheia, nunca o contrario. |
| 99 | static let boottimeTolerance: UInt64 = 10_000_000 |
| 100 | |
| 101 | /// As duas identidades sao do mesmo boot? Desconhecida nunca e. |
| 102 | public static func isSameBoot(_ a: [UInt8], _ b: [UInt8]) -> Bool { |
| 103 | guard a.count == 16, b.count == 16, a != unknownBoot, b != unknownBoot else { return false } |
| 104 | guard Array(a.prefix(8)) == boottimeMark, Array(b.prefix(8)) == boottimeMark else { return a == b } |
| 105 | let x = a.suffix(8).reduce(UInt64(0)) { $0 << 8 | UInt64($1) } |
| 106 | let y = b.suffix(8).reduce(UInt64(0)) { $0 << 8 | UInt64($1) } |
| 107 | return (x > y ? x - y : y - x) <= boottimeTolerance |
| 108 | } |
| 109 | |
| 110 | /// Sem identidade de boot, cada leitura parece um boot novo: a espera recomeca |
| 111 | /// cheia. Falha para o lado do dono esperar mais, nunca menos. |
| 112 | public static let unknownBoot = [UInt8](repeating: 0, count: 16) |
| 113 | |
| 114 | /// O registro de "PIN digitado neste boot": a identidade do boot e o tempo desde o |
| 115 | /// boot naquele instante (24 bytes). |
| 116 | /// |
| 117 | /// So o instante do boot (`kern.boottime`) deixava uma brecha: depois de reiniciar, |
| 118 | /// quem acertasse a hora a mao para o boot antigo cair dentro da folga religava o Face |
| 119 | /// ID sem o PIN (auditoria 3, B2). O tempo desde o boot recomeca do zero ao reiniciar |
| 120 | /// e nao anda com o relogio: o registro so vale se o de agora for maior. |
| 121 | public static func pinRecord(boot: [UInt8], uptime: TimeInterval) -> [UInt8] { |
| 122 | boot + withUnsafeBytes(of: uptime.bitPattern.bigEndian) { Array($0) } |
| 123 | } |
| 124 | |
| 125 | /// Registro antigo, de so 16 bytes, nao vale: o PIN e pedido uma vez. |
| 126 | public static func pinRecordValid(_ saved: [UInt8], boot: [UInt8], uptime: TimeInterval) -> Bool { |
| 127 | guard saved.count == 24 else { return false } |
| 128 | let recordedUptime = TimeInterval(bitPattern: saved.suffix(8).reduce(UInt64(0)) { $0 << 8 | UInt64($1) }) |
| 129 | guard recordedUptime.isFinite, recordedUptime >= 0 else { return false } |
| 130 | return isSameBoot(Array(saved.prefix(16)), boot) && uptime >= recordedUptime |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// O registro de tentativas, gravado no chaveiro. |
| 135 | /// |
| 136 | /// Formato 2 (29 bytes): versao, erros (u32), prazo no relogio monotonico (f64) e a |
| 137 | /// identidade do boot em que o prazo foi gravado (16 bytes). |
| 138 | struct AttemptRecord: Equatable { |
| 139 | var failures: UInt32 |
| 140 | var uptimeDeadline: TimeInterval |
| 141 | var bootSession: [UInt8] |
| 142 | |
| 143 | static let zero = AttemptRecord(failures: 0, uptimeDeadline: 0, bootSession: PINPolicy.unknownBoot) |
| 144 | private static let version: UInt8 = 2 |
| 145 | |
| 146 | var encoded: Data { |
| 147 | var out = Data([Self.version]) |
| 148 | out.append(contentsOf: failures.bigEndianByteArray) |
| 149 | out.append(contentsOf: uptimeDeadline.bitPattern.bigEndianByteArray) |
| 150 | out.append(contentsOf: bootSession) |
| 151 | return out |
| 152 | } |
| 153 | |
| 154 | init(failures: UInt32, uptimeDeadline: TimeInterval, bootSession: [UInt8]) { |
| 155 | self.failures = failures |
| 156 | self.uptimeDeadline = uptimeDeadline |
| 157 | self.bootSession = bootSession |
| 158 | } |
| 159 | |
| 160 | init?(_ data: Data) { |
| 161 | let bytes = [UInt8](data) |
| 162 | switch bytes.count { |
| 163 | case 29 where bytes[0] == Self.version: |
| 164 | failures = bytes[1..<5].reduce(0) { $0 << 8 | UInt32($1) } |
| 165 | uptimeDeadline = Double(bitPattern: bytes[5..<13].reduce(0) { $0 << 8 | UInt64($1) }) |
| 166 | bootSession = Array(bytes[13..<29]) |
| 167 | case 28: |
| 168 | // Formato 1, de antes da auditoria: so o numero de erros ainda vale. O |
| 169 | // boot desconhecido faz a espera recomecar cheia no proximo acesso. |
| 170 | failures = bytes[0..<4].reduce(0) { $0 << 8 | UInt32($1) } |
| 171 | uptimeDeadline = 0 |
| 172 | bootSession = PINPolicy.unknownBoot |
| 173 | default: |
| 174 | return nil |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// Registro do erro numero `failures`, com o prazo contado a partir de agora. |
| 179 | static func after(failures: UInt32) -> AttemptRecord { |
| 180 | AttemptRecord( |
| 181 | failures: failures, |
| 182 | uptimeDeadline: PINPolicy.uptime + PINPolicy.delay(afterFailures: failures), |
| 183 | bootSession: PINPolicy.bootSession |
| 184 | ) |
| 185 | } |
| 186 | |
| 187 | /// O prazo foi gravado em outro boot (ou num formato antigo)? |
| 188 | var isFromAnotherBoot: Bool { |
| 189 | failures > 0 && !PINPolicy.isSameBoot(bootSession, PINPolicy.bootSession) |
| 190 | } |
| 191 | |
| 192 | /// Quanto falta de espera. De outro boot, a resposta e a espera cheia ate alguem |
| 193 | /// regravar o prazo (`RootKeyVault.rebaseAttempts`); ler nunca escreve. |
| 194 | func remaining() -> TimeInterval { |
| 195 | let full = PINPolicy.delay(afterFailures: failures) |
| 196 | guard !isFromAnotherBoot else { return full } |
| 197 | return min(full, max(0, uptimeDeadline - PINPolicy.uptime)) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// O prazo da espera depois que o Face ID caiu (`PINPolicy.biometryLossHold`), gravado no |
| 202 | /// chaveiro. 25 bytes: versao, prazo no relogio monotonico (f64) e a identidade do boot. |
| 203 | /// Mesmo relogio do contador de tentativas: mudar a hora nao encurta, e depois de |
| 204 | /// reiniciar a espera recomeca cheia. |
| 205 | struct HoldRecord: Equatable { |
| 206 | var uptimeDeadline: TimeInterval |
| 207 | var bootSession: [UInt8] |
| 208 | |
| 209 | private static let version: UInt8 = 1 |
| 210 | |
| 211 | static func startingNow() -> HoldRecord { |
| 212 | HoldRecord(uptimeDeadline: PINPolicy.uptime + PINPolicy.biometryLossHold, bootSession: PINPolicy.bootSession) |
| 213 | } |
| 214 | |
| 215 | var encoded: Data { |
| 216 | var out = Data([Self.version]) |
| 217 | out.append(contentsOf: uptimeDeadline.bitPattern.bigEndianByteArray) |
| 218 | out.append(contentsOf: bootSession) |
| 219 | return out |
| 220 | } |
| 221 | |
| 222 | init(uptimeDeadline: TimeInterval, bootSession: [UInt8]) { |
| 223 | self.uptimeDeadline = uptimeDeadline |
| 224 | self.bootSession = bootSession |
| 225 | } |
| 226 | |
| 227 | init?(_ data: Data) { |
| 228 | let bytes = [UInt8](data) |
| 229 | guard bytes.count == 25, bytes[0] == Self.version else { return nil } |
| 230 | uptimeDeadline = Double(bitPattern: bytes[1..<9].reduce(0) { $0 << 8 | UInt64($1) }) |
| 231 | bootSession = Array(bytes[9..<25]) |
| 232 | } |
| 233 | |
| 234 | var isFromAnotherBoot: Bool { !PINPolicy.isSameBoot(bootSession, PINPolicy.bootSession) } |
| 235 | |
| 236 | func remaining() -> TimeInterval { |
| 237 | let full = PINPolicy.biometryLossHold |
| 238 | guard !isFromAnotherBoot, uptimeDeadline.isFinite else { return full } |
| 239 | return min(full, max(0, uptimeDeadline - PINPolicy.uptime)) |
| 240 | } |
| 241 | } |