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 * * *" - cron: "0 8 * * *"
jobs: jobs:
build: build:
# runs-on: macOS-latest runs-on: macOS-latest
runs-on: macos-13
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -20,7 +19,7 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
run: ./.github/scripts/signing.sh run: ./.github/scripts/signing.sh
- name: Set Environment - 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 - name: Update Build Number
env: env:
RUN_ID: ${{ github.run_id }} RUN_ID: ${{ github.run_id }}

View File

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

View File

@ -3,13 +3,12 @@ name: Test
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
test: test:
# runs-on: macOS-latest runs-on: macOS-latest
runs-on: macos-13
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set Environment - 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 - name: Test
run: | run: |
pushd Sources/Packages 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. 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> <img src="/.github/readme/app.png" alt="Screenshot of Secretive" width="600">
<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>
## Why? ## Why?
@ -17,7 +14,7 @@ The most common setup for SSH keys is just keeping them on disk, guarded by prop
### Access Control ### 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"> <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 Foundation
import Combine
/// A protocol for retreiving the latest available version of an app. /// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: ObservableObject { public protocol UpdaterProtocol: ObservableObject {

View File

@ -4,6 +4,25 @@ import OSLog
import SecretKit import SecretKit
import AppKit 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. /// 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 { public class Agent {
@ -11,7 +30,7 @@ public class Agent {
private let witness: SigningWitness? private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer() 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: "") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
/// Initializes an agent with a store list and a witness. /// Initializes an agent with a store list and a witness.
@ -22,7 +41,6 @@ public class Agent {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.witness = witness self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets)
} }
} }
@ -84,26 +102,28 @@ extension Agent {
/// Lists the identities available for signing operations /// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data { func identities() -> Data {
let secrets = storeList.allSecrets let secrets = storeList.stores.flatMap(\.secrets)
certificateHandler.reloadCertificates(for: secrets) var count = UInt32(secrets.count).bigEndian
var count = secrets.count let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data() var keyData = Data()
for secret in secrets { for secret in secrets {
let keyBlob = writer.data(secret: secret) let keyBlob: Data
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! 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: keyBlob))
keyData.append(writer.lengthAndData(of: curveData)) 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") logger.log("Agent enumerated \(secrets.count) identities")
var countBigEndian = UInt32(count).bigEndian
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
return countData + keyData return countData + keyData
} }
@ -114,13 +134,11 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response. /// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let payloadHash = reader.readNextChunk() var hash = reader.readNextChunk()
let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is // Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) { if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
hash = certificatePublicKey hash = certPublicKey
} else {
hash = payloadHash
} }
guard let (store, secret) = secret(matching: hash) else { guard let (store, secret) = secret(matching: hash) else {
@ -182,6 +200,74 @@ extension Agent {
return signedData 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 unhandledType
case noMatchingKey case noMatchingKey
case unsupportedKeyType case unsupportedKeyType
case notOpenSSHCertificate
} }
} }

View File

@ -1,11 +1,11 @@
import Foundation 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 {} public enum SSHAgent {}
extension 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 { public enum RequestType: UInt8, CustomDebugStringConvertible {
case requestIdentities = 11 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 { public enum ResponseType: UInt8, CustomDebugStringConvertible {
case agentFailure = 5 case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12 case agentIdentitiesAnswer = 12
case agentSignResponse = 14 case agentSignResponse = 14
@ -33,8 +32,6 @@ extension SSHAgent {
switch self { switch self {
case .agentFailure: case .agentFailure:
return "AgentFailure" return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer: case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer" return "AgentIdentitiesAnswer"
case .agentSignResponse: case .agentSignResponse:

View File

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

View File

@ -10,7 +10,6 @@ public class AnySecretStore: SecretStore {
private let _name: () -> String private let _name: () -> String
private let _secrets: () -> [AnySecret] private let _secrets: () -> [AnySecret]
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private let _verify: (Data, Data, AnySecret) throws -> Bool
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext? private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private let _reloadSecrets: () -> Void private let _reloadSecrets: () -> Void
@ -24,7 +23,6 @@ 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) }
_verify = { try secretStore.verify(signature: $0, for: $1, with: $2.base as! SecretStoreType.SecretType) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.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) } _persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { secretStore.reloadSecrets() } _reloadSecrets = { secretStore.reloadSecrets() }
@ -53,10 +51,6 @@ public class AnySecretStore: SecretStore {
try _sign(data, secret, provenance) 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? { public func existingPersistedAuthenticationContext(secret: AnySecret) -> PersistedAuthenticationContext? {
_existingPersistedAuthenticationContext(secret) _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 { switch algorithm {
case .ellipticCurve: case .ellipticCurve:
return "ecdsa-sha2-nistp" + String(describing: length) 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 { switch algorithm {
case .ellipticCurve: case .ellipticCurve:
return "nistp" + String(describing: length) 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") logger.log("Writing public keys to disk")
if clear { if clear {
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) })) let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] let untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" } .map { "\(directory)/\($0)" })
let untracked = Set(fullPathContents)
.subtracting(validPaths) .subtracting(validPaths)
for path in untracked { for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path)) try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
@ -47,18 +45,6 @@ public class PublicKeyFileStoreController {
return directory.appending("/").appending("\(minimalHex).pub") 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. /// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for. /// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key. /// - Returns: The path to the SSH Certificate public key.

View File

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

View File

@ -20,7 +20,6 @@ public protocol Secret: Identifiable, Hashable {
public enum Algorithm: Hashable { public enum Algorithm: Hashable {
case ellipticCurve case ellipticCurve
case rsa
/// Initializes the Algorithm with a secAttr representation of an algorithm. /// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber. /// - Parameter secAttr: the secAttr, represented as an NSNumber.
@ -29,19 +28,8 @@ public enum Algorithm: Hashable {
switch secAttrString { switch secAttrString {
case kSecAttrKeyTypeEC: case kSecAttrKeyTypeEC:
self = .ellipticCurve self = .ellipticCurve
case kSecAttrKeyTypeRSA:
self = .rsa
default: default:
fatalError() 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. /// - Returns: The signed data.
func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> 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. /// Checks to see if there is currently a valid persisted authentication for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to check if there is a persisted authentication for. /// - secret: The ``Secret`` to check if there is a persisted authentication for.

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import Combine
import Security import Security
import CryptoTokenKit import CryptoTokenKit
import LocalAuthentication import LocalAuthentication
@ -49,7 +48,7 @@ extension SecureEnclave {
throw error.takeRetainedValue() as Error throw error.takeRetainedValue() as Error
} }
let attributes = KeychainDictionary([ let attributes = [
kSecAttrLabel: name, kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType, kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
@ -58,7 +57,7 @@ extension SecureEnclave {
kSecAttrIsPermanent: true, kSecAttrIsPermanent: true,
kSecAttrAccessControl: access kSecAttrAccessControl: access
] ]
]) ] as CFDictionary
var createKeyError: SecurityError? var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError) let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
@ -73,10 +72,10 @@ extension SecureEnclave {
} }
public func delete(secret: Secret) throws { public func delete(secret: Secret) throws {
let deleteAttributes = KeychainDictionary([ let deleteAttributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrApplicationLabel: secret.id as CFData
]) ] as CFDictionary
let status = SecItemDelete(deleteAttributes) let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
@ -85,14 +84,14 @@ extension SecureEnclave {
} }
public func update(secret: Secret, name: String) throws { public func update(secret: Secret, name: String) throws {
let updateQuery = KeychainDictionary([ let updateQuery = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrApplicationLabel: secret.id as CFData
]) ] as CFDictionary
let updatedAttributes = KeychainDictionary([ let updatedAttributes = [
kSecAttrLabel: name, kSecAttrLabel: name,
]) ] as CFDictionary
let status = SecItemUpdate(updateQuery, updatedAttributes) let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess { if status != errSecSuccess {
@ -101,7 +100,7 @@ extension SecureEnclave {
reloadSecretsInternal() 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 let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid { if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context context = existing.context
@ -111,7 +110,7 @@ extension SecureEnclave {
context = newContext context = newContext
} }
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)\""
let attributes = KeychainDictionary([ let attributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData, kSecAttrApplicationLabel: secret.id as CFData,
@ -120,7 +119,7 @@ extension SecureEnclave {
kSecAttrApplicationTag: Constants.keyTag, kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context, kSecUseAuthenticationContext: context,
kSecReturnRef: true kSecReturnRef: true
]) ] as CFDictionary
var untyped: CFTypeRef? var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped) let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess { if status != errSecSuccess {
@ -138,41 +137,6 @@ extension SecureEnclave {
return signature as Data 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? { public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil } guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
return persisted return persisted
@ -225,7 +189,7 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store. /// Loads all secrets from the store.
private func loadSecrets() { private func loadSecrets() {
let publicAttributes = KeychainDictionary([ let publicAttributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -233,11 +197,11 @@ extension SecureEnclave.Store {
kSecReturnRef: true, kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
]) ] as CFDictionary
var publicUntyped: CFTypeRef? var publicUntyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped) SecItemCopyMatching(publicAttributes, &publicUntyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
let privateAttributes = KeychainDictionary([ let privateAttributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -245,7 +209,7 @@ extension SecureEnclave.Store {
kSecReturnRef: true, kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
]) ] as CFDictionary
var privateUntyped: CFTypeRef? var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped) SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return } guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
@ -283,7 +247,7 @@ extension SecureEnclave.Store {
/// - publicKey: The public key to save. /// - publicKey: The public key to save.
/// - name: A user-facing name for the key. /// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws { private func savePublicKey(_ publicKey: SecKey, name: String) throws {
let attributes = KeychainDictionary([ let attributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecAttrKeyClass: kSecAttrKeyClassPublic,
@ -292,15 +256,37 @@ extension SecureEnclave.Store {
kSecAttrIsPermanent: true, kSecAttrIsPermanent: true,
kSecReturnData: true, kSecReturnData: true,
kSecAttrLabel: name kSecAttrLabel: name
]) ] as CFDictionary
let status = SecItemAdd(attributes, nil) let status = SecItemAdd(attributes, nil)
if status != errSecSuccess { 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 { extension SecureEnclave {
enum Constants { enum Constants {

View File

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

View File

@ -1,5 +1,4 @@
import Foundation import Foundation
import Combine
import Security import Security
import CryptoTokenKit import CryptoTokenKit
import LocalAuthentication import LocalAuthentication
@ -45,19 +44,19 @@ 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: 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() } 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)\""
context.localizedCancelTitle = "Deny" context.localizedCancelTitle = "Deny"
let attributes = KeychainDictionary([ let attributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData, kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrTokenID: tokenID, kSecAttrTokenID: tokenID,
kSecUseAuthenticationContext: context, kSecUseAuthenticationContext: context,
kSecReturnRef: true kSecReturnRef: true
]) ] as CFDictionary
var untyped: CFTypeRef? var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped) let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess { if status != errSecSuccess {
@ -68,40 +67,26 @@ extension SmartCard {
} }
let key = untypedSafe as! SecKey let key = untypedSafe as! SecKey
var signError: SecurityError? 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) throw SigningError(error: signError)
} }
return signature as Data 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 nil
} }
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws { public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
} }
/// Reloads all secrets from the store. /// Reloads all secrets from the store.
@ -151,17 +136,18 @@ extension SmartCard.Store {
} }
} }
let attributes = KeychainDictionary([ let attributes = [
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrTokenID: tokenID, kSecAttrTokenID: tokenID,
kSecAttrKeyType: kSecAttrKeyTypeEC, // Restrict to EC
kSecReturnRef: true, kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
]) ] as CFDictionary
var untyped: CFTypeRef? var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped) SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return } 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 name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let tokenID = $0[kSecAttrApplicationLabel] as! Data let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) 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 { extension TKTokenWatcher {
/// All available tokens, excluding the Secure Enclave. /// 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 var rs = r
rs.append(s) rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs) let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign) let valid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
let store = list.stores.first! XCTAssertTrue(valid)
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)
} }
// MARK: Witness protocol // MARK: Witness protocol

View File

@ -27,7 +27,7 @@ extension Stub {
flags, flags,
nil) as Any nil) as Any
let attributes = KeychainDictionary([ let attributes = [
kSecAttrLabel: name, kSecAttrLabel: name,
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: size, kSecAttrKeySizeInBits: size,
@ -35,10 +35,11 @@ extension Stub {
kSecAttrIsPermanent: true, kSecAttrIsPermanent: true,
kSecAttrAccessControl: access kSecAttrAccessControl: access
] ]
]) ] as CFDictionary
let privateKey = SecKeyCreateRandomKey(attributes, nil)! var privateKey: SecKey! = nil
let publicKey = SecKeyCopyPublicKey(privateKey)! var publicKey: SecKey! = nil
SecKeyGeneratePair(attributes, &publicKey, &privateKey)
let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any] let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any]
let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any] let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any]
let publicData = (publicAttributes[kSecValueData] as! Data) let publicData = (publicAttributes[kSecValueData] as! Data)
@ -52,36 +53,22 @@ extension Stub {
guard !shouldThrow else { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) 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, kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: secret.keySize, kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate kSecAttrKeyClass: kSecAttrKeyClassPrivate
]) ] as CFDictionary
, nil)! , nil)!
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data let signatureAlgorithm: SecKeyAlgorithm
} switch secret.keySize {
case 256:
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool { signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
let attributes = KeychainDictionary([ case 384:
kSecAttrKeyType: secret.algorithm.secAttrKeyType, signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
kSecAttrKeySizeInBits: secret.keySize, default:
kSecAttrKeyClass: kSecAttrKeyClassPublic fatalError()
])
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 key = untypedSafe as! SecKey return SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data
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
} }
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { 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:) self.socketController.handler = self.agent.handle(reader:writer:)
} }
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in 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() notifier.prompt()
updateSink = updater.$update.sink { update in updateSink = updater.$update.sink { update in
guard let update = update else { return } guard let update = update else { return }

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@ struct EmptyStoreImmutableView: View {
VStack { VStack {
Text("No Secrets").bold() Text("No Secrets").bold()
Text("Use your Smart Card's management tool to create a secret.") 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) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} }

View File

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

View File

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