Compare commits

...

12 Commits

Author SHA1 Message Date
5bf5be6c25 Fix popovers not showing on Sonoma beta (#477)
* Use .rect(bounds) on Sonoma.

* Comment
2023-07-23 21:35:28 +00:00
1d4ef12546 Fix #475. (#476) 2023-07-23 21:17:27 +00:00
df10fa3912 Update to Xcode 14.3 (#464)
* Update release.yml

* Update test.yml

* Update nightly.yml
2023-04-30 15:28:54 -07:00
e04fe419ed Update runners to use macOS 13 image (#463)
* Update nightly.yml

* Update release.yml

* Update test.yml
2023-04-30 22:00:23 +00:00
0944d65ccb Identities offers both key and certificate when both are present (#454)
* Identities offers both key and certificate when both are present

* Update Sources/Packages/Sources/SecretAgentKit/Agent.swift

---------

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2023-03-13 00:43:19 +00:00
2ca8279187 Update README.md (#453)
Plurality consistency
2023-03-13 00:40:52 +00:00
be58ddd324 Factor out some common keychain functionality (#456)
* Factor out some common keychain functionality

* Remove redundant

* Remove redundant
2023-03-11 17:58:39 -08:00
93e79470b7 Fixed arg labels (#455) 2023-03-11 17:10:43 -08:00
43a9e287c3 Rounded out the rest of the SmartCardStore API (#450)
* Rounded out the rest of the SmartCardStore API.

* Comments and shuffling around

* Expose verify as public api

* Verification

* Tweak verify signature

* Cleanup and tests

---------

Co-authored-by: Max Goedjen <max.goedjen@gmail.com>
2023-03-12 00:21:09 +00:00
f54b2a33bf Fix a few analyzer/Xcode 13.4b1 warnings (#449)
* Fix missing combine imports

* Fix a few other new warnings
2023-02-19 01:37:38 +00:00
3bd8e3b494 Light/dark readme images (#438)
* New image

* Light/dark images

* Update README.md
2022-12-23 21:54:49 +00:00
14b351abee New image (#437) 2022-12-23 17:54:43 +00:00
24 changed files with 365 additions and 135 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

BIN
.github/readme/app.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 KiB

View File

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

View File

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

View File

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

View File

@ -3,7 +3,10 @@
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.
<img src="/.github/readme/app.png" alt="Screenshot of Secretive" width="600">
<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>
## Why?
@ -14,7 +17,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 key 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 keys 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,4 +1,5 @@
import Foundation
import Combine
/// A protocol for retreiving the latest available version of an app.
public protocol UpdaterProtocol: ObservableObject {

View File

@ -86,27 +86,24 @@ extension Agent {
func identities() -> Data {
let secrets = storeList.allSecrets
certificateHandler.reloadCertificates(for: secrets)
var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var count = secrets.count
var keyData = Data()
for secret in secrets {
let keyBlob: Data
let curveData: Data
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) {
keyBlob = certificateData
curveData = name
} else {
keyBlob = writer.data(secret: secret)
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
}
let keyBlob = writer.data(secret: secret)
let 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 \(secrets.count) identities")
}
logger.log("Agent enumerated \(count) identities")
var countBigEndian = UInt32(count).bigEndian
let countData = Data(bytes: &countBigEndian, count: UInt32.bitWidth/8)
return countData + keyData
}

View File

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

View File

@ -10,6 +10,7 @@ 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
@ -23,6 +24,7 @@ public class AnySecretStore: SecretStore {
_id = { secretStore.id }
_secrets = { secretStore.secrets.map { AnySecret($0) } }
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_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() }
@ -51,6 +53,10 @@ 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

@ -0,0 +1,71 @@
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

@ -64,6 +64,10 @@ 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"
}
}
@ -76,6 +80,9 @@ 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,8 +20,10 @@ 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 untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
.map { "\(directory)/\($0)" })
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
let fullPathContents = contentsOfDirectory.map { "\(directory)/\($0)" }
let untracked = Set(fullPathContents)
.subtracting(validPaths)
for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))

View File

@ -20,6 +20,7 @@ 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.
@ -28,8 +29,19 @@ 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,6 +23,14 @@ 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,4 +1,5 @@
import Foundation
import Combine
import Security
import CryptoTokenKit
import LocalAuthentication
@ -48,7 +49,7 @@ extension SecureEnclave {
throw error.takeRetainedValue() as Error
}
let attributes = [
let attributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
@ -57,7 +58,7 @@ extension SecureEnclave {
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
] as CFDictionary
])
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
@ -72,10 +73,10 @@ extension SecureEnclave {
}
public func delete(secret: Secret) throws {
let deleteAttributes = [
let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
] as CFDictionary
])
let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
@ -84,14 +85,14 @@ extension SecureEnclave {
}
public func update(secret: Secret, name: String) throws {
let updateQuery = [
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
] as CFDictionary
])
let updatedAttributes = [
let updatedAttributes = KeychainDictionary([
kSecAttrLabel: name,
] as CFDictionary
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
@ -100,7 +101,7 @@ extension SecureEnclave {
reloadSecretsInternal()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
let context: LAContext
if let existing = persistedAuthenticationContexts[secret], existing.valid {
context = existing.context
@ -110,7 +111,7 @@ extension SecureEnclave {
context = newContext
}
context.localizedReason = "sign a request from \"\(provenance.origin.displayName)\" using secret \"\(secret.name)\""
let attributes = [
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
@ -119,7 +120,7 @@ extension SecureEnclave {
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
] as CFDictionary
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
@ -137,6 +138,41 @@ 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
@ -189,7 +225,7 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store.
private func loadSecrets() {
let publicAttributes = [
let publicAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -197,11 +233,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 = [
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
@ -209,7 +245,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 }
@ -247,7 +283,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 = [
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: SecureEnclave.Constants.keyType,
kSecAttrKeyClass: kSecAttrKeyClassPublic,
@ -256,37 +292,15 @@ extension SecureEnclave.Store {
kSecAttrIsPermanent: true,
kSecReturnData: true,
kSecAttrLabel: name
] as CFDictionary
])
let status = SecItemAdd(attributes, nil)
if status != errSecSuccess {
throw SecureEnclave.KeychainError(statusCode: status)
throw 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,9 +6,3 @@
- ``Secret``
- ``Store``
### Errors
- ``KeychainError``
- ``SigningError``
- ``SecurityError``

View File

@ -1,4 +1,5 @@
import Foundation
import Combine
import Security
import CryptoTokenKit
import LocalAuthentication
@ -44,19 +45,19 @@ extension SmartCard {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
public func sign(data: Data, with secret: Secret, 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 = [
let attributes = KeychainDictionary([
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 {
@ -67,26 +68,40 @@ extension SmartCard {
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
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 {
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: SmartCard.Secret) -> PersistedAuthenticationContext? {
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? {
nil
}
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
@ -136,18 +151,17 @@ extension SmartCard.Store {
}
}
let attributes = [
let attributes = KeychainDictionary([
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: [SmartCard.Secret] = typed.map {
let wrapped = typed.map {
let name = $0[kSecAttrLabel] as? String ?? "Unnamed"
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
@ -163,6 +177,88 @@ 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.
@ -171,25 +267,3 @@ 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,8 +61,17 @@ class AgentTests: XCTestCase {
var rs = r
rs.append(s)
let signature = try! P256.Signing.ECDSASignature(rawRepresentation: rs)
let valid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
XCTAssertTrue(valid)
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)
}
// MARK: Witness protocol

View File

@ -27,7 +27,7 @@ extension Stub {
flags,
nil) as Any
let attributes = [
let attributes = KeychainDictionary([
kSecAttrLabel: name,
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: size,
@ -35,11 +35,10 @@ extension Stub {
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
] as CFDictionary
])
var privateKey: SecKey! = nil
var publicKey: SecKey! = nil
SecKeyGeneratePair(attributes, &publicKey, &privateKey)
let privateKey = SecKeyCreateRandomKey(attributes, nil)!
let publicKey = SecKeyCopyPublicKey(privateKey)!
let publicAttributes = SecKeyCopyAttributes(publicKey) as! [CFString: Any]
let privateAttributes = SecKeyCopyAttributes(privateKey) as! [CFString: Any]
let publicData = (publicAttributes[kSecValueData] as! Data)
@ -53,22 +52,36 @@ extension Stub {
guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil)
}
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, [
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
] as CFDictionary
])
, nil)!
let signatureAlgorithm: SecKeyAlgorithm
switch secret.keySize {
case 256:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
case 384:
signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
default:
fatalError()
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
}
return SecKeyCreateSignature(privateKey, signatureAlgorithm, 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 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
}
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {

View File

@ -40,6 +40,10 @@ 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

@ -88,7 +88,7 @@ extension ContentView {
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: color))
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
UpdateDetailView(update: update)
}
}
@ -147,7 +147,7 @@ extension ContentView {
}
})
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
.popover(isPresented: $showingAgentInfo, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
VStack {
Text("SecretAgent is Running")
.font(.title)
@ -172,7 +172,7 @@ extension ContentView {
.foregroundColor(.white)
})
.buttonStyle(ToolbarButtonStyle(color: .orange))
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
VStack {
Image(systemName: "exclamationmark.triangle")
.resizable()
@ -186,6 +186,11 @@ extension ContentView {
}
}
var attachmentAnchor: PopoverAttachmentAnchor {
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
.rect(.bounds)
}
}
#if DEBUG

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 only supports Elliptic Curve keys.")
Text("Secretive supports EC256, EC384, RSA1024, and RSA2048 keys.")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}