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 dataToSign = reader.readNextChunk()
let signed = try store.sign(data: dataToSign, with: secret, for: provenance) 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)! 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)) signedData.append(writer.lengthAndData(of: sub))
if let witness = witness { 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") 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. /// - secret: The `Secret` that will was used to sign the request.
/// - store: The `Store` that signed the request.. /// - store: The `Store` that signed the request..
/// - provenance: A `SigningRequestProvenance` object describing the origin of 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) throws
func witness(accessTo secret: AnySecret, from store: AnySecretStore, by provenance: SigningRequestProvenance, requiredAuthentication: Bool) throws
} }

View File

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

View File

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

View File

@ -9,7 +9,8 @@ public class AnySecretStore: SecretStore {
private let _id: () -> UUID private let _id: () -> UUID
private let _name: () -> String private let _name: () -> String
private let _secrets: () -> [AnySecret] 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 let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private var sink: AnyCancellable? private var sink: AnyCancellable?
@ -21,6 +22,7 @@ public class AnySecretStore: SecretStore {
_id = { secretStore.id } _id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } } _secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) } _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) } _persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
sink = secretStore.objectWillChange.sink { _ in sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send() self.objectWillChange.send()
@ -43,10 +45,14 @@ public class AnySecretStore: SecretStore {
return _secrets() 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) try _sign(data, secret, provenance)
} }
public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? {
_existingPersistedAuthenticationContext(secret)
}
public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws { public func persistAuthentication(secret: AnySecret, forDuration duration: TimeInterval) throws {
try _persistAuthentication(secret, duration) 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 } var algorithm: Algorithm { get }
/// The key size for the secret. /// The key size for the secret.
var keySize: Int { get } var keySize: Int { get }
/// Whether the secret requires authentication before use.
var requiresAuthentication: Bool { get }
/// The public key data for the secret. /// The public key data for the secret.
var publicKey: Data { get } var publicKey: Data { get }

View File

@ -20,8 +20,14 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - data: The data to sign. /// - data: The data to sign.
/// - secret: The ``Secret`` to sign with. /// - secret: The ``Secret`` to sign with.
/// - provenance: A ``SigningRequestProvenance`` describing where the request came from. /// - provenance: A ``SigningRequestProvenance`` describing where the request came from.
/// - Returns: A ``SignedData`` object, containing the signature and metadata about the signature process. /// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> SignedData 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. /// Persists user authorization for access to a secret.
/// - Parameters: /// - 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 name: String
public let algorithm = Algorithm.ellipticCurve public let algorithm = Algorithm.ellipticCurve
public let keySize = 256 public let keySize = 256
public let requiresAuthentication: Bool
public let publicKey: Data public let publicKey: Data
} }

View File

@ -100,7 +100,7 @@ extension SecureEnclave {
reloadSecrets() 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 let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid { if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context context = existing.context
@ -131,16 +131,15 @@ extension SecureEnclave {
let key = untypedSafe as! SecKey let key = untypedSafe as! SecKey
var signError: SecurityError? var signError: SecurityError?
let signingStartTime = Date()
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else { guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError) throw SigningError(error: signError)
} }
let signatureDuration = Date().timeIntervalSince(signingStartTime) return signature as Data
// 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 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 { public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) throws {
@ -183,7 +182,7 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { private func loadSecrets() {
let attributes = [ let publicAttributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -192,16 +191,46 @@ extension SecureEnclave.Store {
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
] as CFDictionary ] as CFDictionary
var untyped: CFTypeRef? var publicUntyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped) SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let typed = untyped as? [[CFString: Any]] else { return } guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let wrapped: [SecureEnclave.Secret] = typed.map { 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 name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let id = $0[kSecAttrApplicationLabel] as! Data let id = $0[kSecAttrApplicationLabel] as! Data
let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeyRef = $0[kSecValueRef] as! SecKey
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data 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) secrets.append(contentsOf: wrapped)
} }
@ -264,7 +293,7 @@ extension SecureEnclave {
extension SecureEnclave { extension SecureEnclave {
/// A context describing a persisted authentication. /// A context describing a persisted authentication.
private struct PersistentAuthenticationContext { private struct PersistentAuthenticationContext: PersistedAuthenticationContext {
/// The Secret to persist authentication for. /// The Secret to persist authentication for.
let secret: Secret let secret: Secret
@ -272,7 +301,7 @@ extension SecureEnclave {
let context: LAContext let context: LAContext
/// An expiration date for the context. /// An expiration date for the context.
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back. /// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
let expiration: UInt64 let monotonicExpiration: UInt64
/// Initializes a context. /// Initializes a context.
/// - Parameters: /// - Parameters:
@ -283,12 +312,18 @@ extension SecureEnclave {
self.secret = secret self.secret = secret
self.context = context self.context = context
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value 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. /// A boolean describing whether or not the context is still valid.
var valid: Bool { 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 name: String
public let algorithm: Algorithm public let algorithm: Algorithm
public let keySize: Int public let keySize: Int
public let requiresAuthentication: Bool = false
public let publicKey: Data public let publicKey: Data
} }

View File

@ -44,7 +44,7 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.") 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() } guard let tokenID = tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\"" 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 { guard let signature = SecKeyCreateSignature(key, signatureAlgorithm, data as CFData, &signError) else {
throw SigningError(error: signError) 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 { 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))") 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 { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) throw NSError(domain: "test", code: 0, userInfo: nil)
} }
@ -68,7 +68,11 @@ extension Stub {
default: default:
fatalError() 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 { public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
@ -88,6 +92,7 @@ extension Stub {
let keySize: Int let keySize: Int
let publicKey: Data let publicKey: Data
let requiresAuthentication = false
let privateKey: Data let privateKey: Data
init(keySize: Int, publicKey: Data, 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) witness(secret, provenance)
} }

View File

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

View File

@ -11,6 +11,7 @@ extension Preview {
let name: String let name: String
let algorithm = Algorithm.ellipticCurve let algorithm = Algorithm.ellipticCurve
let keySize = 256 let keySize = 256
let requiresAuthentication: Bool = false
let publicKey = UUID().uuidString.data(using: .utf8)! let publicKey = UUID().uuidString.data(using: .utf8)!
} }
@ -35,8 +36,12 @@ extension Preview {
self.secrets.append(contentsOf: new) self.secrets.append(contentsOf: new)
} }
func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> SignedData { func sign(data: Data, with secret: Preview.Secret, for provenance: SigningRequestProvenance) throws -> Data {
return SignedData(data: data, requiredAuthentication: false) return data
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
} }
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws { 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) { return NavigationLink(destination: SecretDetailView(secret: secret), tag: secret.id, selection: $activeSecret) {
if secret.requiresAuthentication {
HStack {
Text(secret.name) Text(secret.name)
Spacer()
Image(systemName: "lock")
}
} else {
Text(secret.name)
}
}.contextMenu { }.contextMenu {
if store is AnySecretStoreModifiable { if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) { Button(action: { isRenaming = true }) {