Files

129 lines
4.4 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Darwin
import Foundation
/// Centralized clearing for short-lived buffers that temporarily contain secrets.
public nonisolated enum SecretMemory {
public static func clear(_ bytes: inout Data) {
bytes.withUnsafeMutableBytes { raw in
guard let base = raw.baseAddress, raw.count > 0 else { return }
_ = memset_s(base, raw.count, 0, raw.count)
}
bytes.removeAll(keepingCapacity: false)
}
}
/// A one-shot UTF-8 secret used only while authenticating a connection.
///
/// Copies of an `SSHSecret` share one backing object. Consuming or clearing any
/// reference therefore invalidates every reference. The plaintext lives in one
/// explicitly owned, non-copy-on-write allocation that is overwritten before it
/// is released.
public nonisolated final class SSHSecret: @unchecked Sendable {
public struct Consumed: Error, Sendable {
public init() {}
}
private struct Storage {
var pointer: UnsafeMutableRawPointer
var count: Int
var didZeroize: (@Sendable (UnsafeRawBufferPointer) -> Void)?
func clearAndDeallocate() {
_ = memset_s(pointer, count + 1, 0, count + 1)
didZeroize?(UnsafeRawBufferPointer(start: pointer, count: count + 1))
pointer.deallocate()
}
}
private let lock = NSLock()
private var storage: Storage?
public convenience init(_ value: String) {
var bytes = Data(value.utf8)
self.init(consumingUTF8: &bytes)
}
/// Copies a freshly read UTF-8 buffer into wipeable ownership, then clears
/// the source buffer before returning. The final allocation includes a NUL
/// terminator without asking `Data` to grow or reallocate its plaintext.
public init(consumingUTF8 bytes: inout Data) {
let count = bytes.count
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: count + 1,
alignment: MemoryLayout<UInt8>.alignment
)
if count > 0 {
bytes.withUnsafeBytes { raw in
guard let source = raw.baseAddress else { return }
pointer.copyMemory(from: source, byteCount: count)
}
}
pointer.storeBytes(of: UInt8(0), toByteOffset: count, as: UInt8.self)
SecretMemory.clear(&bytes)
storage = Storage(pointer: pointer, count: count)
}
deinit { clear() }
public var byteCount: Int { lock.withLock { storage?.count ?? 0 } }
public var isConsumed: Bool {
lock.withLock { storage == nil }
}
/// Exposes the UTF-8 bytes once, including a trailing NUL outside `count`.
/// The allocation is wiped before this method returns or throws.
public func withUnsafeCStringAndConsume<Result>(
_ body: (UnsafePointer<CChar>, Int) throws -> Result
) throws -> Result {
let storage = try take()
defer { storage.clearAndDeallocate() }
return try body(
UnsafePointer(storage.pointer.assumingMemoryBound(to: CChar.self)),
storage.count
)
}
/// Test support without extending the secret's lifetime.
public func matches(_ value: String) -> Bool {
let expected = Array(value.utf8)
return lock.withLock {
guard let storage, storage.count == expected.count else { return false }
let bytes = UnsafeRawBufferPointer(start: storage.pointer, count: storage.count)
return bytes.elementsEqual(expected)
}
}
public func clear() {
guard
let storage = lock.withLock({ () -> Storage? in
let value = storage
self.storage = nil
return value
})
else { return }
storage.clearAndDeallocate()
}
/// Internal test seam: observes the allocation only after memset_s and
/// before deallocation, never after the pointer has become invalid.
func observeZeroization(_ observer: @escaping @Sendable (UnsafeRawBufferPointer) -> Void) {
lock.withLock { storage?.didZeroize = observer }
}
private func take() throws -> Storage {
try lock.withLock {
guard let value = storage else { throw Consumed() }
storage = nil
return value
}
}
}
public nonisolated func == (lhs: SSHSecret, rhs: String) -> Bool {
lhs.matches(rhs)
}