Statically load whether or not a key requires authentication before use (#357)

* Add protocol requirements

* Load auth settings.

* Updates.

* Update preview store

* Add lock icon
This commit is contained in:
Max Goedjen 2022-02-24 22:59:35 -08:00 committed by GitHub
parent ae7394f771
commit a1009d0dac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 129 additions and 58 deletions

View File

@ -113,7 +113,7 @@ extension Agent {
let dataToSign = reader.readNextChunk()
let signed = try store.sign(data: dataToSign, with: secret, for: provenance)
let derSignature = signed.data
let derSignature = signed
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
@ -154,7 +154,7 @@ extension Agent {
signedData.append(writer.lengthAndData(of: sub))
if let witness = witness {
try witness.witness(accessTo: secret, from: store, by: provenance, requiredAuthentication: signed.requiredAuthentication)
try witness.witness(accessTo: secret, from: store, by: provenance)
}
Logger().debug("Agent signed request")

View File

@ -17,7 +17,6 @@ public protocol SigningWitness {
/// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of the request.
/// - requiredAuthentication: A boolean describing whether or not authentication was required for the request.
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, requiredAuthentication: Bool) throws
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws
}

View File

@ -27,5 +27,8 @@ SecretKit is a collection of protocols describing secrets and stores.
### Signing Process
- ``SignedData``
- ``SigningRequestProvenance``
### Authentication Persistence
- ``PersistedAuthenticationContext``

View File

@ -9,6 +9,7 @@ public struct AnySecret: Secret {
private let _name: () -> String
private let _algorithm: () -> Algorithm
private let _keySize: () -> Int
private let _requiresAuthentication: () -> Bool
private let _publicKey: () -> Data
public init<T>(_ secret: T) where T: Secret {
@ -19,6 +20,7 @@ public struct AnySecret: Secret {
_name = secret._name
_algorithm = secret._algorithm
_keySize = secret._keySize
_requiresAuthentication = secret._requiresAuthentication
_publicKey = secret._publicKey
} else {
base = secret as Any
@ -27,6 +29,7 @@ public struct AnySecret: Secret {
_name = { secret.name }
_algorithm = { secret.algorithm }
_keySize = { secret.keySize }
_requiresAuthentication = { secret.requiresAuthentication }
_publicKey = { secret.publicKey }
}
}
@ -47,6 +50,10 @@ public struct AnySecret: Secret {
_keySize()
}
public var requiresAuthentication: Bool {
_requiresAuthentication()
}
public var publicKey: Data {
_publicKey()
}

View File

@ -9,7 +9,8 @@ public class AnySecretStore: SecretStore {
private let _id: () -> UUID
private let _name: () -> String
private let _secrets: () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> SignedData
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private var sink: AnyCancellable?
@ -21,6 +22,7 @@ public class AnySecretStore: SecretStore {
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send()
@ -43,10 +45,14 @@ public class AnySecretStore: SecretStore {
return _secrets()
}
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> SignedData {
public func sign(data: Data, with secret: AnySecret, for provenance: SigningRequestProvenance) throws -> Data {
try _sign(data, secret, provenance)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? {
_existingPersistedAuthenticationContext(secret)
}
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws {
try _persistAuthentication(secret, duration)
}

View File

@ -0,0 +1,9 @@
import Foundation
/// Protocol describing a persisted authentication context. This is an authorization that can be reused for multiple access to a secret that requires authentication for a specific period of time.
public protocol PersistedAuthenticationContext {
/// Whether the context remains valid.
var valid: Bool { get }
/// The date at which the authorization expires and the context becomes invalid.
var expiration: Date { get }
}

View File

@ -9,6 +9,8 @@ public protocol Secret: Identifiable, Hashable {
var algorithm: Algorithm { get }
/// The key size for the secret.
var keySize: Int { get }
/// Whether the secret requires authentication before use.
var requiresAuthentication: Bool { get }
/// The public key data for the secret.
var publicKey: Data { get }

View File

@ -20,8 +20,14 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - data: The data to sign.
/// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: A ``SignedData`` object, containing the signature and metadata about the signature process.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> SignedData
/// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data
/// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for.
/// - Returns: A persisted authentication context, if a valid one exists.
func existingPersistedAuthenticationContext(secret: SecretType) -> PersistedAuthenticationContext?
/// Persists user authorization for access to a secret.
/// - Parameters:

View File

@ -1,20 +0,0 @@
import Foundation
/// Describes the output of a sign request.
public struct SignedData {
/// The signed data.
public let data: Data
/// A boolean describing whether authentication was required during the signature process.
public let requiredAuthentication: Bool
/// Initializes a new SignedData.
/// - Parameters:
/// - data: The signed data.
/// - requiredAuthentication: A boolean describing whether authentication was required during the signature process.
public init(data: Data, requiredAuthentication: Bool) {
self.data = data
self.requiredAuthentication = requiredAuthentication
}
}

View File

@ -11,6 +11,7 @@ extension SecureEnclave {
public let name: String
public let algorithm = Algorithm.ellipticCurve
public let keySize = 256
public let requiresAuthentication: Bool
public let publicKey: Data
}

View File

@ -100,7 +100,7 @@ extension SecureEnclave {
reloadSecrets()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> SignedData {
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context
@ -131,16 +131,15 @@ extension SecureEnclave {
let key = untypedSafe as! SecKey
var signError: SecurityError?
let signingStartTime = Date()
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
let signatureDuration = Date().timeIntervalSince(signingStartTime)
// Hack to determine if the user had to authenticate to sign.
// Since there's now way to inspect SecAccessControl to determine (afaict).
let requiredAuthentication = signatureDuration > Constants.unauthenticatedThreshold
return signature as Data
}
return SignedData(data: signature as Data, requiredAuthentication: requiredAuthentication)
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
@ -183,7 +182,7 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store.
private func loadSecrets() {
let attributes = [
let publicAttributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -192,16 +191,46 @@ extension SecureEnclave.Store {
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
] as CFDictionary
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = typed.map {
var publicUntyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let privateAttributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
] as CFDictionary
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
let id = next[kSecAttrApplicationLabel] as! Data
partialResult[id] = next
}
let authNotRequiredAccessControl: SecAccessControl =
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage],
nil)!
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
let name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey)
let privateKey = privateMapped[id]
let requiresAuth: Bool
if let authRequirements = privateKey?[kSecAttrAccessControl] {
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality.
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl
} else {
requiresAuth = false
}
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
}
secrets.append(contentsOf: wrapped)
}
@ -264,7 +293,7 @@ extension SecureEnclave {
extension SecureEnclave {
/// A context describing a persisted authentication.
private struct PersistentAuthenticationContext {
private struct PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for.
let secret: Secret
@ -272,7 +301,7 @@ extension SecureEnclave {
let context: LAContext
/// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let expiration: UInt64
let monotonicExpiration: UInt64
/// Initializes a context.
/// - Parameters:
@ -283,12 +312,18 @@ extension SecureEnclave {
self.secret = secret
self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
self.expiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
}
/// A boolean describing whether or not the context is still valid.
var valid: Bool {
clock_gettime_nsec_np(CLOCK_MONOTONIC) < expiration
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
}
var expiration: Date {
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
return Date(timeIntervalSinceNow: remainingInSeconds)
}
}

View File

@ -11,6 +11,7 @@ extension SmartCard {
public let name: String
public let algorithm: Algorithm
public let keySize: Int
public let requiresAuthentication: Bool = false
public let publicKey: Data
}

View File

@ -44,7 +44,7 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> SignedData {
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
@ -79,7 +79,11 @@ extension SmartCard {
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return SignedData(data: signature as Data, requiredAuthentication: false)
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: SmartCard.Secret) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {

View File

@ -49,7 +49,7 @@ extension Stub {
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> SignedData {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
@ -68,7 +68,11 @@ extension Stub {
default:
fatalError()
}
return SignedData(data: SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data, requiredAuthentication: false)
return SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
@ -88,6 +92,7 @@ extension Stub {
let keySize: Int
let publicKey: Data
let requiresAuthentication = false
let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) {

View File

@ -17,7 +17,7 @@ func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: A
}
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, requiredAuthentication: Bool) throws {
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
witness(secret, provenance)
}

View File

@ -57,7 +57,7 @@ class Notifier {
notificationCenter.requestAuthorization(options: .alert) { _, _ in }
}
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, requiredAuthentication: Bool) {
func notify(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) {
notificationDelegate.pendingPersistableSecrets[secret.id.description] = secret
notificationDelegate.pendingPersistableStores[store.id.description] = store
let notificationCenter = UNUserNotificationCenter.current()
@ -69,7 +69,7 @@ class Notifier {
if #available(macOS 12.0, *) {
notificationContent.interruptionLevel = .timeSensitive
}
if requiredAuthentication {
if secret.requiresAuthentication && store.existingPersistedAuthenticationContext(secret: secret) == nil {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
}
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
@ -106,8 +106,8 @@ extension Notifier: SigningWitness {
func speakNowOrForeverHoldYourPeace(forAccessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
}
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, requiredAuthentication: Bool) throws {
notify(accessTo: secret, from: store, by: provenance, requiredAuthentication: requiredAuthentication)
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance) throws {
notify(accessTo: secret, from: store, by: provenance)
}
}

View File

@ -11,6 +11,7 @@ extension Preview {
let name: String
let algorithm = Algorithm.ellipticCurve
let keySize = 256
let requiresAuthentication: Bool = false
let publicKey = UUID().uuidString.data(using: .utf8)!
}
@ -35,8 +36,12 @@ extension Preview {
self.secrets.append(contentsOf: new)
}
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> SignedData {
return SignedData(data: data, requiredAuthentication: false)
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
return data
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {

View File

@ -20,7 +20,15 @@ struct SecretListItemView: View {
)
return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) {
Text(secret.name)
if secret.requiresAuthentication {
HStack {
Text(secret.name)
Spacer()
Image(systemName: "lock")
}
} else {
Text(secret.name)
}
}.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {