Compare commits

..

3 Commits

Author SHA1 Message Date
8597d86ee8 Move file 2022-12-22 18:06:58 -05:00
efef3b3412 Toolbar button style 2022-12-22 17:55:53 -05:00
d429d090cf Switch toolbar items to viewbuilders 2022-12-22 17:20:03 -05:00
32 changed files with 245 additions and 535 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

BIN
.github/readme/app.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

View File

@ -5,8 +5,7 @@ on:
- cron: "0 8 * * *"
jobs:
build:
# runs-on: macOS-latest
runs-on: macos-13
runs-on: macOS-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
@ -20,7 +19,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
- name: Update Build Number
env:
RUN_ID: ${{ github.run_id }}

View File

@ -6,8 +6,7 @@ on:
- '*'
jobs:
test:
# runs-on: macOS-latest
runs-on: macos-13
runs-on: macOS-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v1
@ -21,15 +20,14 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
- name: Test
run: |
pushd Sources/Packages
swift test
popd
build:
# runs-on: macOS-latest
runs-on: macos-13
runs-on: macOS-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
@ -43,7 +41,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
- name: Update Build Number
env:
TAG_NAME: ${{ github.ref }}

View File

@ -3,13 +3,12 @@ name: Test
on: [push, pull_request]
jobs:
test:
# runs-on: macOS-latest
runs-on: macos-13
runs-on: macOS-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- name: Set Environment
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
run: sudo xcrun xcode-select -s /Applications/Xcode_14.1.app
- name: Test
run: |
pushd Sources/Packages

View File

@ -3,10 +3,7 @@
Secretive is an app for storing and managing SSH keys in the Secure Enclave. It is inspired by the [sekey project](https://github.com/sekey/sekey), but rewritten in Swift with no external dependencies and with a handy native management app.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/readme/app-dark.png">
<img src="/.github/readme/app-light.png" alt="Screenshot of Secretive" width="600">
</picture>
<img src="/.github/readme/app.png" alt="Screenshot of Secretive" width="600">
## Why?
@ -17,7 +14,7 @@ The most common setup for SSH keys is just keeping them on disk, guarded by prop
### Access Control
If your Mac has a Secure Enclave, it also has support for strong access controls like Touch ID, or authentication with Apple Watch. You can configure your keys so that they require Touch ID (or Watch) authentication before they're accessed.
If your Mac has a Secure Enclave, it also has support for strong access controls like Touch ID, or authentication with Apple Watch. You can configure your key so that they require Touch ID (or Watch) authentication before they're accessed.
<img src="/.github/readme/touchid.png" alt="Screenshot of Secretive authenticating with Touch ID" width="400">

View File

@ -1,5 +1,4 @@
import Foundation
import Combine
/// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: ObservableObject {

View File

@ -4,6 +4,25 @@ import OSLog
import SecretKit
import AppKit
enum OpenSSHCertificateError: Error {
case unsupportedType
case parsingFailed
case doesNotExist
}
extension OpenSSHCertificateError: CustomStringConvertible {
public var description: String {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public class Agent {
@ -11,7 +30,7 @@ public class Agent {
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
private let certificateHandler = OpenSSHCertificateHandler()
private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
/// Initializes an agent with a store list and a witness.
@ -22,7 +41,6 @@ public class Agent {
logger.debug("Agent is running")
self.storeList = storeList
self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets)
}
}
@ -84,26 +102,28 @@ extension Agent {
/// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data {
let secrets = storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets)
var count = secrets.count
let secrets = storeList.stores.flatMap(\.secrets)
var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data()
for secret in secrets {
let keyBlob = writer.data(secret: secret)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
let keyBlob: Data
let curveData: Data
if let (certBlob, certName) = try? checkForCert(secret: secret) {
keyBlob = certBlob
curveData = certName
} else {
keyBlob = writer.data(secret: secret)
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
}
keyData.append(writer.lengthAndData(of: keyBlob))
keyData.append(writer.lengthAndData(of: curveData))
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name))
count += 1
}
}
logger.log("Agent enumerated \(count) identities")
var countBigEndian = UInt32(count).bigEndian
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
logger.log("Agent enumerated \(secrets.count) identities")
return countData + keyData
}
@ -114,13 +134,11 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data)
let payloadHash = reader.readNextChunk()
let hash: Data
var hash = reader.readNextChunk()
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
hash = certificatePublicKey
} else {
hash = payloadHash
if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
hash = certPublicKey
}
guard let (store, secret) = secret(matching: hash) else {
@ -182,6 +200,74 @@ extension Agent {
return signedData
}
/// Reconstructs a public key from a ``Data`` object that contains an OpenSSH certificate. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format
func getPublicKeyFromCert(certBlob: Data) throws -> Data {
let reader = OpenSSHReader(data: certBlob)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
if let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8) {
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
} else {
throw OpenSSHCertificateError.parsingFailed
}
default:
throw OpenSSHCertificateError.unsupportedType
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: Two ``Data`` objects containing the certificate and certificate name respectively
func checkForCert(secret: AnySecret) throws -> (Data, Data) {
let minimalHex = writer.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
let certificatePath = certsPath.appending("/").appending("\(minimalHex)-cert.pub")
if FileManager.default.fileExists(atPath: certificatePath) {
logger.debug("Found certificate for \(secret.name)")
do {
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
if certElements.count >= 2 {
if let certDecoded = Data(base64Encoded: certElements[1] as String) {
if certElements.count >= 3 {
if let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
} else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
}
} catch {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
}
throw OpenSSHCertificateError.doesNotExist
}
}
@ -222,7 +308,6 @@ extension Agent {
case unhandledType
case noMatchingKey
case unsupportedKeyType
case notOpenSSHCertificate
}
}

View File

@ -1,11 +1,11 @@
import Foundation
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
/// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
public enum SSHAgent {}
extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
/// The type of the SSH Agent Request, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
public enum RequestType: UInt8, CustomDebugStringConvertible {
case requestIdentities = 11
@ -21,11 +21,10 @@ extension SSHAgent {
}
}
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
/// The type of the SSH Agent Response, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
public enum ResponseType: UInt8, CustomDebugStringConvertible {
case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12
case agentSignResponse = 14
@ -33,8 +32,6 @@ extension SSHAgent {
switch self {
case .agentFailure:
return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer"
case .agentSignResponse:

View File

@ -32,9 +32,3 @@ SecretKit is a collection of protocols describing secrets and stores.
### Authentication Persistence
- ``PersistedAuthenticationContext``
### Errors
- ``KeychainError``
- ``SigningError``
- ``SecurityError``

View File

@ -10,7 +10,6 @@ public class AnySecretStore: SecretStore {
private let _name: () -> String
private let _secrets: () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private let _verify: (Data, Data, AnySecret) throws -> Bool
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private let _reloadSecrets: () -> Void
@ -24,7 +23,6 @@ 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) }
_verify = { try secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { secretStore.reloadSecrets() }
@ -53,10 +51,6 @@ public class AnySecretStore: SecretStore {
try _sign(data, secret, provenance)
}
public func verify(signature: Data, for data: Data, with secret: AnySecret) throws -> Bool {
try _verify(signature, data, secret)
}
public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? {
_existingPersistedAuthenticationContext(secret)
}

View File

@ -1,71 +0,0 @@
import Foundation
public typealias SecurityError = Unmanaged<CFError>
/// Wraps a Swift dictionary in a CFDictionary.
/// - Parameter dictionary: The Swift dictionary to wrap.
/// - Returns: A CFDictionary containing the keys and values.
public func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary {
dictionary as CFDictionary
}
public extension CFError {
/// The CFError returned when a verification operation fails.
static let verifyError = CFErrorCreate(nil, NSOSStatusErrorDomain as CFErrorDomain, CFIndex(errSecVerifyFailed), nil)!
/// Equality operation that only considers domain and code.
static func ~=(lhs: CFError, rhs: CFError) -> Bool {
CFErrorGetDomain(lhs) == CFErrorGetDomain(rhs) && CFErrorGetCode(lhs) == CFErrorGetCode(rhs)
}
}
/// A wrapper around an error code reported by a Keychain API.
public struct KeychainError: Error {
/// The status code involved, if one was reported.
public let statusCode: OSStatus?
/// Initializes a KeychainError with an optional error code.
/// - Parameter statusCode: The status code returned by the keychain operation, if one is applicable.
public init(statusCode: OSStatus?) {
self.statusCode = statusCode
}
}
/// A signing-related error.
public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned.
public let error: SecurityError?
/// Initializes a SigningError with an optional SecurityError.
/// - Parameter statusCode: The SecurityError, if one is applicable.
public init(error: SecurityError?) {
self.error = error
}
}
public extension SecretStore {
/// Returns the appropriate keychian signature algorithm to use for a given secret.
/// - Parameters:
/// - secret: The secret which will be used for signing.
/// - allowRSA: Whether or not RSA key types should be permited.
/// - Returns: The appropriate algorithm.
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm {
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
return .ecdsaSignatureMessageX962SHA256
case (.ellipticCurve, 384):
return .ecdsaSignatureMessageX962SHA384
case (.rsa, 1024), (.rsa, 2048):
guard allowRSA else { fatalError() }
return .rsaSignatureMessagePKCS1v15SHA512
default:
fatalError()
}
}
}

View File

@ -1,120 +0,0 @@
import Foundation
import OSLog
/// Manages storage and lookup for OpenSSH certificates.
public class OpenSSHCertificateHandler {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger()
private let writer = OpenSSHKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
public init() {
}
/// Reloads any certificates in the PublicKeys folder.
/// - Parameter secrets: the secrets to look up corresponding certificates for.
public func reloadCertificates(for secrets: [AnySecret]) {
guard publicKeyFileStoreController.hasAnyCertificates else {
logger.log("No certificates, short circuiting")
return
}
keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in
partialResult[next] = try? loadKeyblobAndName(for: next)
}
}
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames[AnySecret(secret)] != nil
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self)
switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
public func keyBlobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
keyBlobsAndNames[AnySecret(secret)]
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively.
private func loadKeyblobAndName<SecretType: Secret>(for secret: SecretType) throws -> (Data, Data)? {
let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret)
guard FileManager.default.fileExists(atPath: certificatePath) else {
return nil
}
logger.debug("Found certificate for \(secret.name)")
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
guard certElements.count >= 2 else {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
guard let certDecoded = Data(base64Encoded: certElements[1] as String) else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
}
extension OpenSSHCertificateHandler {
enum OpenSSHCertificateError: LocalizedError {
case unsupportedType
case parsingFailed
case doesNotExist
public var errorDescription: String? {
switch self {
case .unsupportedType:
return "The key type was unsupported"
case .parsingFailed:
return "Failed to properly parse the SSH certificate"
case .doesNotExist:
return "Certificate does not exist"
}
}
}
}

View File

@ -64,10 +64,6 @@ extension OpenSSHKeyWriter {
switch algorithm {
case .ellipticCurve:
return "ecdsa-sha2-nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function, per
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
return "rsa-sha2-512"
}
}
@ -80,9 +76,6 @@ extension OpenSSHKeyWriter {
switch algorithm {
case .ellipticCurve:
return "nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function
return "rsa-sha2-512"
}
}

View File

@ -20,10 +20,8 @@ public class PublicKeyFileStoreController {
logger.log("Writing public keys to disk")
if clear {
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let untracked = Set(fullPathContents)
let untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
.map { "\(directory)/\($0)" })
.subtracting(validPaths)
for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
@ -47,18 +45,6 @@ public class PublicKeyFileStoreController {
return directory.appending("/").appending("\(minimalHex).pub")
}
/// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory.
public var hasAnyCertificates: Bool {
do {
return try FileManager.default
.contentsOfDirectory(atPath: directory)
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
return false
}
}
/// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key.

View File

@ -8,7 +8,7 @@ public class SecretStoreList: ObservableObject {
@Published public var stores: [AnySecretStore] = []
/// A modifiable store, if one is available.
@Published public var modifiableStore: AnySecretStoreModifiable?
private var cancellables: Set<AnyCancellable> = []
private var sinks: [AnyCancellable] = []
/// Initializes a SecretStoreList.
public init() {
@ -31,19 +31,16 @@ public class SecretStoreList: ObservableObject {
stores.reduce(false, { $0 || $1.isAvailable })
}
public var allSecrets: [AnySecret] {
stores.flatMap(\.secrets)
}
}
extension SecretStoreList {
private func addInternal(store: AnySecretStore) {
stores.append(store)
store.objectWillChange.sink {
let sink = store.objectWillChange.sink {
self.objectWillChange.send()
}.store(in: &cancellables)
}
sinks.append(sink)
}
}

View File

@ -20,7 +20,6 @@ public protocol Secret: Identifiable, Hashable {
public enum Algorithm: Hashable {
case ellipticCurve
case rsa
/// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
@ -29,19 +28,8 @@ public enum Algorithm: Hashable {
switch secAttrString {
case kSecAttrKeyTypeEC:
self = .ellipticCurve
case kSecAttrKeyTypeRSA:
self = .rsa
default:
fatalError()
}
}
public var secAttrKeyType: CFString {
switch self {
case .ellipticCurve:
return kSecAttrKeyTypeEC
case .rsa:
return kSecAttrKeyTypeRSA
}
}
}

View File

@ -23,14 +23,6 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data
/// Verifies that a signature is valid over a specified payload.
/// - Parameters:
/// - signature: The signature over the data.
/// - data: The data to verify the signature of.
/// - secret: The secret whose signature to verify.
/// - Returns: Whether the signature was verified.
func verify(signature: Data, for data: Data, with secret: SecretType) throws -> Bool
/// 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.

View File

@ -1,5 +1,4 @@
import Foundation
import Combine
import Security
import CryptoTokenKit
import LocalAuthentication
@ -49,7 +48,7 @@ extension SecureEnclave {
throw error.takeRetainedValue() as Error
}
let attributes = KeychainDictionary([
let attributes = [
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
@ -58,7 +57,7 @@ extension SecureEnclave {
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
] as CFDictionary
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
@ -73,10 +72,10 @@ extension SecureEnclave {
}
public func delete(secret: Secret) throws {
let deleteAttributes = KeychainDictionary([
let deleteAttributes = [
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
] as CFDictionary
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
@ -85,14 +84,14 @@ extension SecureEnclave {
}
public func update(secret: Secret, name: String) throws {
let updateQuery = KeychainDictionary([
let updateQuery = [
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
] as CFDictionary
let updatedAttributes = KeychainDictionary([
let updatedAttributes = [
kSecAttrLabel: name,
])
] as CFDictionary
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
@ -101,7 +100,7 @@ extension SecureEnclave {
reloadSecretsInternal()
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
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
@ -111,7 +110,7 @@ extension SecureEnclave {
context = newContext
}
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
let attributes = KeychainDictionary([
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
@ -120,7 +119,7 @@ extension SecureEnclave {
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
] as CFDictionary
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
@ -138,41 +137,6 @@ extension SecureEnclave {
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let context = LAContext()
context.localizedReason = "verify a signature using secret \"\(secret.name)\""
context.localizedCancelTitle = "Deny"
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var verifyError: SecurityError?
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted
@ -225,7 +189,7 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store.
private func loadSecrets() {
let publicAttributes = KeychainDictionary([
let publicAttributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -233,11 +197,11 @@ extension SecureEnclave.Store {
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
] as CFDictionary
var publicUntyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let privateAttributes = KeychainDictionary([
let privateAttributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -245,7 +209,7 @@ extension SecureEnclave.Store {
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
] as CFDictionary
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
@ -283,7 +247,7 @@ extension SecureEnclave.Store {
/// - publicKey: The public key to save.
/// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
let attributes = KeychainDictionary([
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
@ -292,15 +256,37 @@ extension SecureEnclave.Store {
kSecAttrIsPermanent: true,
kSecReturnData: true,
kSecAttrLabel: name
])
] as CFDictionary
let status = SecItemAdd(attributes, nil)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
throw SecureEnclave.KeychainError(statusCode: status)
}
}
}
extension SecureEnclave {
/// A wrapper around an error code reported by a Keychain API.
public struct KeychainError: Error {
/// The status code involved, if one was reported.
public let statusCode: OSStatus?
}
/// A signing-related error.
public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned.
public let error: SecurityError?
}
}
extension SecureEnclave {
public typealias SecurityError = Unmanaged<CFError>
}
extension SecureEnclave {
enum Constants {

View File

@ -6,3 +6,9 @@
- ``Secret``
- ``Store``
### Errors
- ``KeychainError``
- ``SigningError``
- ``SecurityError``

View File

@ -1,5 +1,4 @@
import Foundation
import Combine
import Security
import CryptoTokenKit
import LocalAuthentication
@ -45,19 +44,19 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
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)\""
context.localizedCancelTitle = "Deny"
let attributes = KeychainDictionary([
let attributes = [
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
] as CFDictionary
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
@ -68,40 +67,26 @@ extension SmartCard {
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
let signatureAlgorithm: SecKeyAlgorithm
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
case (.ellipticCurve, 384):
signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
default:
fatalError()
}
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func verify(signature: Data, for data: Data, with secret: Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, signature as CFData, &verifyError)
if !verified, let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw SigningError(error: verifyError)
}
}
return verified
}
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
public func existingPersistedAuthenticationContext(secret: SmartCard.Secret) -> PersistedAuthenticationContext? {
nil
}
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
@ -151,17 +136,18 @@ extension SmartCard.Store {
}
}
let attributes = KeychainDictionary([
let attributes = [
kSecClass: kSecClassKey,
kSecAttrTokenID: tokenID,
kSecAttrKeyType: kSecAttrKeyTypeEC, // Restrict to EC
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
] as CFDictionary
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map {
let wrapped: [SmartCard.Secret] = typed.map {
let name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
@ -177,88 +163,6 @@ extension SmartCard.Store {
}
// MARK: Smart Card specific encryption/decryption/verification
extension SmartCard.Store {
/// Encrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to encrypt.
/// - secret: The secret to encrypt with.
/// - Returns: The encrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func encrypt(data: Data, with secret: SecretType) throws -> Data {
let context = LAContext()
context.localizedReason = "encrypt data using secret \"\(secret.name)\""
context.localizedCancelTitle = "Deny"
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
kSecUseAuthenticationContext: context
])
var encryptError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &encryptError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
guard let signature = SecKeyCreateEncryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
/// Decrypts a payload with a specified key.
/// - Parameters:
/// - data: The payload to decrypt.
/// - secret: The secret to decrypt with.
/// - Returns: The decrypted data.
/// - Warning: Encryption functions are deliberately only exposed on a library level, and are not exposed in Secretive itself to prevent users from data loss. Any pull requests which expose this functionality in the app will not be merged.
public func decrypt(data: Data, with secret: SecretType) throws -> Data {
guard let tokenID = tokenID else { fatalError() }
let context = LAContext()
context.localizedReason = "decrypt data using secret \"\(secret.name)\""
context.localizedCancelTitle = "Deny"
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var encryptError: SecurityError?
guard let signature = SecKeyCreateDecryptedData(key, encryptionAlgorithm(for: secret), data as CFData, &encryptError) else {
throw SigningError(error: encryptError)
}
return signature as Data
}
private func encryptionAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
case (.ellipticCurve, 384):
return .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
case (.rsa, 1024), (.rsa, 2048):
return .rsaEncryptionOAEPSHA512AESGCM
default:
fatalError()
}
}
}
extension TKTokenWatcher {
/// All available tokens, excluding the Secure Enclave.
@ -267,3 +171,25 @@ extension TKTokenWatcher {
}
}
extension SmartCard {
/// A wrapper around an error code reported by a Keychain API.
public struct KeychainError: Error {
/// The status code involved.
public let statusCode: OSStatus
}
/// A signing-related error.
public struct SigningError: Error {
/// The underlying error reported by the API, if one was returned.
public let error: SecurityError?
}
}
extension SmartCard {
public typealias SecurityError = Unmanaged<CFError>
}

View File

@ -61,17 +61,8 @@ class AgentTests: XCTestCase {
var rs = r
rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
let store = list.stores.first!
let derVerifies = try! store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomSignature = try? store.verify(signature: "invalid".data(using: .utf8)!, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidRandomData = try? store.verify(signature: signature.derRepresentation, for: "invalid".data(using: .utf8)!, with: AnySecret(Constants.Secrets.ecdsa256Secret))
let invalidWrongKey = try? store.verify(signature: signature.derRepresentation, for: dataToSign, with: AnySecret(Constants.Secrets.ecdsa384Secret))
XCTAssertTrue(referenceValid)
XCTAssertTrue(derVerifies)
XCTAssert(invalidRandomSignature == false)
XCTAssert(invalidRandomData == false)
XCTAssert(invalidWrongKey == false)
let valid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
XCTAssertTrue(valid)
}
// MARK: Witness protocol

View File

@ -27,7 +27,7 @@ extension Stub {
flags,
nil) as Any
let attributes = KeychainDictionary([
let attributes = [
kSecAttrLabel: name,
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: size,
@ -35,10 +35,11 @@ extension Stub {
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
] as CFDictionary
let privateKey = SecKeyCreateRandomKey(attributes, nil)!
let publicKey = SecKeyCopyPublicKey(privateKey)!
var privateKey: SecKey! = nil
var publicKey: SecKey! = nil
SecKeyGeneratePair(attributes, &publicKey, &privateKey)
let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any]
let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any]
let publicData = (publicAttributes[kSecValueData] as! Data)
@ -52,36 +53,22 @@ extension Stub {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, [
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
])
] as CFDictionary
, nil)!
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
}
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
let attributes = KeychainDictionary([
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: Unmanaged<CFError>?
let untyped: CFTypeRef? = SecKeyCreateWithData(secret.publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw NSError(domain: "test", code: 0, userInfo: nil)
let signatureAlgorithm: SecKeyAlgorithm
switch secret.keySize {
case 256:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
case 384:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
default:
fatalError()
}
let key = untypedSafe as! SecKey
let verified = SecKeyVerifySignature(key, signatureAlgorithm(for: secret), data as CFData, signature as CFData, &verifyError)
if let verifyError {
if verifyError.takeUnretainedValue() ~= .verifyError {
return false
} else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
}
return verified
return SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {

View File

@ -34,9 +34,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.socketController.handler = self.agent.handle(reader:writer:)
}
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
}
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
notifier.prompt()
updateSink = updater.$update.sink { update in
guard let update = update else { return }

View File

@ -266,7 +266,6 @@
isa = PBXGroup;
children = (
50617D8423FCE48E0099B055 /* ContentView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
@ -278,6 +277,7 @@
50153E1F250AFCB200525160 /* UpdateView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
);
path = Views;
sourceTree = "<group>";

View File

@ -40,10 +40,6 @@ extension Preview {
return data
}
func verify(signature data: Data, for signature: Data, with secret: Preview.Secret) throws -> Bool {
true
}
func existingPersistedAuthenticationContext(secret: Preview.Secret) -> PersistedAuthenticationContext? {
nil
}

View File

@ -10,7 +10,6 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
@Binding var runningSetup: Bool
@Binding var hasRunSetup: Bool
@State var showingAgentInfo = false
@State var activeSecret: AnySecret.ID?
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject private var storeList: SecretStoreList
@ -23,7 +22,7 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
var body: some View {
VStack {
if storeList.anyAvailable {
StoreListView(activeSecret: $activeSecret)
StoreListView(showingCreation: $showingCreation)
} else {
NoStoresView()
}
@ -88,7 +87,7 @@ extension ContentView {
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: color))
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
}
@ -105,10 +104,6 @@ extension ContentView {
.sheet(isPresented: $showingCreation) {
if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation)
.onDisappear {
guard let newest = modifiable.secrets.last?.id else { return }
activeSecret = newest
}
}
}
}
@ -147,7 +142,7 @@ extension ContentView {
}
})
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
.popover(isPresented: $showingAgentInfo, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
VStack {
Text("SecretAgent is Running")
.font(.title)
@ -172,7 +167,7 @@ extension ContentView {
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: .orange))
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
VStack {
Image(systemName: "exclamationmark.triangle")
.resizable()
@ -186,11 +181,6 @@ extension ContentView {
}
}
var attachmentAnchor: PopoverAttachmentAnchor {
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
.rect(.bounds)
}
}
#if DEBUG

View File

@ -35,6 +35,7 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
Spacer()
Button("Delete", action: delete)
.disabled(confirm != secret.name)
.keyboardShortcut(.delete)
Button("Don't Delete") {
dismissalBlock(false)
}

View File

@ -34,7 +34,7 @@ struct EmptyStoreImmutableView: View {
VStack {
Text("No Secrets").bold()
Text("Use your Smart Card's management tool to create a secret.")
Text("Secretive supports EC256, EC384, RSA1024, and RSA2048 keys.")
Text("Secretive only supports Elliptic Curve keys.")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}

View File

@ -29,8 +29,7 @@ struct SecretListItemView: View {
} else {
Text(secret.name)
}
}
.contextMenu {
}.contextMenu {
if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) {
Text("Rename")

View File

@ -1,11 +1,12 @@
import SwiftUI
import Combine
import SecretKit
struct StoreListView: View {
@Binding var activeSecret: AnySecret.ID?
@Binding var showingCreation: Bool
@State private var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList
private func secretDeleted(secret: AnySecret) {
@ -13,7 +14,7 @@ struct StoreListView: View {
}
private func secretRenamed(secret: AnySecret) {
activeSecret = secret.id
activeSecret = nextDefaultSecret
}
var body: some View {