mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-07-01 17:53:36 +00:00
Compare commits
15 Commits
viewbuilde
...
v2.3.1
Author | SHA1 | Date | |
---|---|---|---|
5bf5be6c25 | |||
1d4ef12546 | |||
df10fa3912 | |||
e04fe419ed | |||
0944d65ccb | |||
2ca8279187 | |||
be58ddd324 | |||
93e79470b7 | |||
43a9e287c3 | |||
f54b2a33bf | |||
3bd8e3b494 | |||
14b351abee | |||
480ef5392d | |||
8679ca3da0 | |||
f43571baa3 |
BIN
.github/readme/app-dark.png
vendored
Normal file
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
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
BIN
.github/readme/app.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 580 KiB |
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@ -5,7 +5,8 @@ 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
|
||||||
@ -19,7 +20,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.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
RUN_ID: ${{ github.run_id }}
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@ -6,7 +6,8 @@ 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
|
||||||
@ -20,14 +21,15 @@ 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.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.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
|
||||||
@ -41,7 +43,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.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
|
||||||
- name: Update Build Number
|
- name: Update Build Number
|
||||||
env:
|
env:
|
||||||
TAG_NAME: ${{ github.ref }}
|
TAG_NAME: ${{ github.ref }}
|
||||||
|
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@ -3,12 +3,13 @@ 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.1.app
|
run: sudo xcrun xcode-select -s /Applications/Xcode_14.3.app
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
pushd Sources/Packages
|
pushd Sources/Packages
|
||||||
|
@ -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.
|
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?
|
## Why?
|
||||||
@ -14,7 +17,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 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">
|
<img src="/.github/readme/touchid.png" alt="Screenshot of Secretive authenticating with Touch ID" width="400">
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
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 {
|
||||||
|
@ -4,25 +4,6 @@ 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 {
|
||||||
|
|
||||||
@ -30,7 +11,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 certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
|
private let certificateHandler = OpenSSHCertificateHandler()
|
||||||
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.
|
||||||
@ -41,6 +22,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -102,28 +84,26 @@ 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.stores.flatMap(\.secrets)
|
let secrets = storeList.allSecrets
|
||||||
var count = UInt32(secrets.count).bigEndian
|
certificateHandler.reloadCertificates(for: secrets)
|
||||||
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
var count = secrets.count
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let keyBlob: Data
|
let keyBlob = writer.data(secret: secret)
|
||||||
let curveData: Data
|
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
||||||
|
|
||||||
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 \(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
|
return countData + keyData
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,11 +114,13 @@ 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)
|
||||||
var hash = reader.readNextChunk()
|
let payloadHash = 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 certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
|
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) {
|
||||||
hash = certPublicKey
|
hash = certificatePublicKey
|
||||||
|
} else {
|
||||||
|
hash = payloadHash
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let (store, secret) = secret(matching: hash) else {
|
guard let (store, secret) = secret(matching: hash) else {
|
||||||
@ -200,74 +182,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,6 +222,7 @@ extension Agent {
|
|||||||
case unhandledType
|
case unhandledType
|
||||||
case noMatchingKey
|
case noMatchingKey
|
||||||
case unsupportedKeyType
|
case unsupportedKeyType
|
||||||
|
case notOpenSSHCertificate
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
|
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
public enum SSHAgent {}
|
public enum SSHAgent {}
|
||||||
|
|
||||||
extension SSHAgent {
|
extension SSHAgent {
|
||||||
|
|
||||||
/// 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
|
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
|
||||||
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
public enum RequestType: UInt8, CustomDebugStringConvertible {
|
||||||
|
|
||||||
case requestIdentities = 11
|
case requestIdentities = 11
|
||||||
@ -21,10 +21,11 @@ extension SSHAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#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
|
||||||
|
|
||||||
@ -32,6 +33,8 @@ 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:
|
||||||
|
@ -32,3 +32,9 @@ SecretKit is a collection of protocols describing secrets and stores.
|
|||||||
### Authentication Persistence
|
### Authentication Persistence
|
||||||
|
|
||||||
- ``PersistedAuthenticationContext``
|
- ``PersistedAuthenticationContext``
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
- ``KeychainError``
|
||||||
|
- ``SigningError``
|
||||||
|
- ``SecurityError``
|
||||||
|
@ -10,6 +10,7 @@ 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
|
||||||
@ -23,6 +24,7 @@ public class AnySecretStore: SecretStore {
|
|||||||
_id = { secretStore.id }
|
_id = { secretStore.id }
|
||||||
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
_secrets = { secretStore.secrets.map { AnySecret($0) } }
|
||||||
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
|
||||||
|
_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() }
|
||||||
@ -51,6 +53,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
71
Sources/Packages/Sources/SecretKit/KeychainTypes.swift
Normal file
71
Sources/Packages/Sources/SecretKit/KeychainTypes.swift
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,120 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -64,6 +64,10 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +80,9 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,8 +20,10 @@ 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 untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
|
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? []
|
||||||
.map { "\(directory)/\($0)" })
|
let fullPathContents = contentsOfDirectory.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))
|
||||||
@ -45,6 +47,18 @@ 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.
|
||||||
|
@ -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 sinks: [AnyCancellable] = []
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
/// Initializes a SecretStoreList.
|
/// Initializes a SecretStoreList.
|
||||||
public init() {
|
public init() {
|
||||||
@ -31,16 +31,19 @@ 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)
|
||||||
let sink = store.objectWillChange.sink {
|
store.objectWillChange.sink {
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}.store(in: &cancellables)
|
||||||
sinks.append(sink)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ 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.
|
||||||
@ -28,8 +29,19 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,14 @@ 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.
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import Security
|
import Security
|
||||||
import CryptoTokenKit
|
import CryptoTokenKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
@ -48,7 +49,7 @@ extension SecureEnclave {
|
|||||||
throw error.takeRetainedValue() as Error
|
throw error.takeRetainedValue() as Error
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes = [
|
let attributes = KeychainDictionary([
|
||||||
kSecAttrLabel: name,
|
kSecAttrLabel: name,
|
||||||
kSecAttrKeyType: Constants.keyType,
|
kSecAttrKeyType: Constants.keyType,
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
||||||
@ -57,7 +58,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)
|
||||||
@ -72,10 +73,10 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: Secret) throws {
|
public func delete(secret: Secret) throws {
|
||||||
let deleteAttributes = [
|
let deleteAttributes = KeychainDictionary([
|
||||||
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)
|
||||||
@ -84,14 +85,14 @@ extension SecureEnclave {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func update(secret: Secret, name: String) throws {
|
public func update(secret: Secret, name: String) throws {
|
||||||
let updateQuery = [
|
let updateQuery = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData
|
kSecAttrApplicationLabel: secret.id as CFData
|
||||||
] as CFDictionary
|
])
|
||||||
|
|
||||||
let updatedAttributes = [
|
let updatedAttributes = KeychainDictionary([
|
||||||
kSecAttrLabel: name,
|
kSecAttrLabel: name,
|
||||||
] as CFDictionary
|
])
|
||||||
|
|
||||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
@ -100,7 +101,7 @@ extension SecureEnclave {
|
|||||||
reloadSecretsInternal()
|
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
|
let context: LAContext
|
||||||
if let existing = persistedAuthenticationContexts[secret], existing.valid {
|
if let existing = persistedAuthenticationContexts[secret], existing.valid {
|
||||||
context = existing.context
|
context = existing.context
|
||||||
@ -110,7 +111,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 = [
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
kSecAttrApplicationLabel: secret.id as CFData,
|
||||||
@ -119,7 +120,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 {
|
||||||
@ -137,6 +138,41 @@ 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
|
||||||
@ -189,7 +225,7 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
private func loadSecrets() {
|
private func loadSecrets() {
|
||||||
let publicAttributes = [
|
let publicAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
||||||
@ -197,11 +233,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 = [
|
let privateAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
||||||
@ -209,7 +245,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 }
|
||||||
@ -247,7 +283,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 = [
|
let attributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
||||||
@ -256,37 +292,15 @@ 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 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 {
|
extension SecureEnclave {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
|
@ -6,9 +6,3 @@
|
|||||||
|
|
||||||
- ``Secret``
|
- ``Secret``
|
||||||
- ``Store``
|
- ``Store``
|
||||||
|
|
||||||
### Errors
|
|
||||||
|
|
||||||
- ``KeychainError``
|
|
||||||
- ``SigningError``
|
|
||||||
- ``SecurityError``
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import Security
|
import Security
|
||||||
import CryptoTokenKit
|
import CryptoTokenKit
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
@ -44,19 +45,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: 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() }
|
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 = [
|
let attributes = KeychainDictionary([
|
||||||
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 {
|
||||||
@ -67,26 +68,40 @@ extension SmartCard {
|
|||||||
}
|
}
|
||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
let signatureAlgorithm: SecKeyAlgorithm
|
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
|
||||||
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: SmartCard.Secret) -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
|
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
/// Reloads all secrets from the store.
|
||||||
@ -136,18 +151,17 @@ extension SmartCard.Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let attributes = [
|
let attributes = KeychainDictionary([
|
||||||
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: [SmartCard.Secret] = typed.map {
|
let wrapped = 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)
|
||||||
@ -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 {
|
extension TKTokenWatcher {
|
||||||
|
|
||||||
/// All available tokens, excluding the Secure Enclave.
|
/// 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>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
@ -61,8 +61,17 @@ 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 valid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
let referenceValid = try! P256.Signing.PublicKey(x963Representation: Constants.Secrets.ecdsa256Secret.publicKey).isValidSignature(signature, for: dataToSign)
|
||||||
XCTAssertTrue(valid)
|
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
|
// MARK: Witness protocol
|
||||||
|
@ -27,7 +27,7 @@ extension Stub {
|
|||||||
flags,
|
flags,
|
||||||
nil) as Any
|
nil) as Any
|
||||||
|
|
||||||
let attributes = [
|
let attributes = KeychainDictionary([
|
||||||
kSecAttrLabel: name,
|
kSecAttrLabel: name,
|
||||||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
kSecAttrKeySizeInBits: size,
|
kSecAttrKeySizeInBits: size,
|
||||||
@ -35,11 +35,10 @@ extension Stub {
|
|||||||
kSecAttrIsPermanent: true,
|
kSecAttrIsPermanent: true,
|
||||||
kSecAttrAccessControl: access
|
kSecAttrAccessControl: access
|
||||||
]
|
]
|
||||||
] as CFDictionary
|
])
|
||||||
|
|
||||||
var privateKey: SecKey! = nil
|
let privateKey = SecKeyCreateRandomKey(attributes, nil)!
|
||||||
var publicKey: SecKey! = nil
|
let publicKey = SecKeyCopyPublicKey(privateKey)!
|
||||||
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)
|
||||||
@ -53,22 +52,36 @@ 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, [
|
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
|
||||||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
kSecAttrKeySizeInBits: secret.keySize,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate
|
||||||
] as CFDictionary
|
])
|
||||||
, nil)!
|
, nil)!
|
||||||
let signatureAlgorithm: SecKeyAlgorithm
|
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
|
||||||
switch secret.keySize {
|
}
|
||||||
case 256:
|
|
||||||
signatureAlgorithm = .ecdsaSignatureMessageX962SHA256
|
public func verify(signature: Data, for data: Data, with secret: Stub.Secret) throws -> Bool {
|
||||||
case 384:
|
let attributes = KeychainDictionary([
|
||||||
signatureAlgorithm = .ecdsaSignatureMessageX962SHA384
|
kSecAttrKeyType: secret.algorithm.secAttrKeyType,
|
||||||
default:
|
kSecAttrKeySizeInBits: secret.keySize,
|
||||||
fatalError()
|
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)
|
||||||
}
|
}
|
||||||
return SecKeyCreateSignature(privateKey, signatureAlgorithm, data as CFData, nil)! as Data
|
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? {
|
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
||||||
|
@ -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.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)
|
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, 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 }
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
|
50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; };
|
||||||
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
|
50617D9923FCE48E0099B055 /* SecretiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D9823FCE48E0099B055 /* SecretiveTests.swift */; };
|
||||||
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; };
|
50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617DD123FCEFA90099B055 /* PreviewStore.swift */; };
|
||||||
|
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5065E312295517C500E16645 /* ToolbarButtonStyle.swift */; };
|
||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C12516F303004B5A36 /* SetupView.swift */; };
|
||||||
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6C72516FE6E004B5A36 /* CopyableView.swift */; };
|
||||||
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
|
5066A6F7251829B1004B5A36 /* ShellConfigurationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */; };
|
||||||
@ -123,6 +124,7 @@
|
|||||||
50617D9823FCE48E0099B055 /* SecretiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveTests.swift; sourceTree = "<group>"; };
|
50617D9823FCE48E0099B055 /* SecretiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveTests.swift; sourceTree = "<group>"; };
|
||||||
50617D9A23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
50617D9A23FCE48E0099B055 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = "<group>"; };
|
50617DD123FCEFA90099B055 /* PreviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewStore.swift; sourceTree = "<group>"; };
|
||||||
|
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButtonStyle.swift; sourceTree = "<group>"; };
|
||||||
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
5066A6C12516F303004B5A36 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||||
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
|
5066A6C72516FE6E004B5A36 /* CopyableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableView.swift; sourceTree = "<group>"; };
|
||||||
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
|
5066A6F6251829B1004B5A36 /* ShellConfigurationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellConfigurationController.swift; sourceTree = "<group>"; };
|
||||||
@ -264,6 +266,7 @@
|
|||||||
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 */,
|
||||||
@ -475,6 +478,7 @@
|
|||||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
||||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||||
|
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||||
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
50617D8523FCE48E0099B055 /* ContentView.swift in Sources */,
|
||||||
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */,
|
||||||
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */,
|
||||||
|
@ -40,6 +40,10 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,9 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
@Binding var showingCreation: Bool
|
@Binding var showingCreation: Bool
|
||||||
@Binding var runningSetup: Bool
|
@Binding var runningSetup: Bool
|
||||||
@Binding var hasRunSetup: Bool
|
@Binding var hasRunSetup: Bool
|
||||||
|
@State var showingAgentInfo = false
|
||||||
|
@State var activeSecret: AnySecret.ID?
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@EnvironmentObject private var storeList: SecretStoreList
|
@EnvironmentObject private var storeList: SecretStoreList
|
||||||
@EnvironmentObject private var updater: UpdaterType
|
@EnvironmentObject private var updater: UpdaterType
|
||||||
@ -20,17 +23,17 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if storeList.anyAvailable {
|
if storeList.anyAvailable {
|
||||||
StoreListView(showingCreation: $showingCreation)
|
StoreListView(activeSecret: $activeSecret)
|
||||||
} else {
|
} else {
|
||||||
NoStoresView()
|
NoStoresView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 640, minHeight: 320)
|
.frame(minWidth: 640, minHeight: 320)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
updateNotice
|
toolbarItem(updateNoticeView, id: "update")
|
||||||
setupNotice
|
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||||
appPathNotice
|
toolbarItem(appPathNoticeView, id: "appPath")
|
||||||
newItem
|
toolbarItem(newItemView, id: "new")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $runningSetup) {
|
.sheet(isPresented: $runningSetup) {
|
||||||
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
SetupView(visible: $runningSetup, setupComplete: $hasRunSetup)
|
||||||
@ -41,124 +44,153 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
|
|||||||
|
|
||||||
extension ContentView {
|
extension ContentView {
|
||||||
|
|
||||||
var updateNotice: ToolbarItem<Void, AnyView> {
|
|
||||||
guard let update = updater.update else {
|
func toolbarItem(_ view: some View, id: String) -> ToolbarItem<String, some View> {
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
ToolbarItem(id: id) { view }
|
||||||
|
}
|
||||||
|
|
||||||
|
var needsSetup: Bool {
|
||||||
|
(runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item either showing a "everything's good, here's more info" or "something's wrong, re-run setup" message
|
||||||
|
/// These two are mutually exclusive
|
||||||
|
@ViewBuilder
|
||||||
|
var runningOrRunSetupView: some View {
|
||||||
|
if needsSetup {
|
||||||
|
setupNoticeView
|
||||||
|
} else {
|
||||||
|
runningNoticeView
|
||||||
}
|
}
|
||||||
let color: Color
|
}
|
||||||
let text: String
|
|
||||||
|
var updateNoticeContent: (String, Color)? {
|
||||||
|
guard let update = updater.update else { return nil }
|
||||||
if update.critical {
|
if update.critical {
|
||||||
text = "Critical Security Update Required"
|
return ("Critical Security Update Required", .red)
|
||||||
color = .red
|
|
||||||
} else {
|
} else {
|
||||||
if updater.testBuild {
|
if updater.testBuild {
|
||||||
text = "Test Build"
|
return ("Test Build", .blue)
|
||||||
color = .blue
|
|
||||||
} else {
|
} else {
|
||||||
text = "Update Available"
|
return ("Update Available", .orange)
|
||||||
color = .orange
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Button(action: {
|
|
||||||
selectedUpdate = update
|
|
||||||
}, label: {
|
|
||||||
Text(text)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
})
|
|
||||||
.background(color)
|
|
||||||
.cornerRadius(5)
|
|
||||||
.popover(item: $selectedUpdate, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) { update in
|
|
||||||
UpdateDetailView(update: update)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var newItem: ToolbarItem<Void, AnyView> {
|
@ViewBuilder
|
||||||
guard storeList.modifiableStore?.isAvailable ?? false else {
|
var updateNoticeView: some View {
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
if let update = updater.update, let (text, color) = updateNoticeContent {
|
||||||
}
|
Button(action: {
|
||||||
return ToolbarItem {
|
selectedUpdate = update
|
||||||
AnyView(
|
}, label: {
|
||||||
Button(action: {
|
Text(text)
|
||||||
showingCreation = true
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
})
|
|
||||||
.sheet(isPresented: $showingCreation) {
|
|
||||||
if let modifiable = storeList.modifiableStore {
|
|
||||||
CreateSecretView(store: modifiable, showing: $showingCreation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var setupNotice: ToolbarItem<Void, AnyView> {
|
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Group {
|
|
||||||
if (runningSetup || !hasRunSetup || !agentStatusChecker.running) && !agentStatusChecker.developmentBuild {
|
|
||||||
Button(action: {
|
|
||||||
runningSetup = true
|
|
||||||
}, label: {
|
|
||||||
Group {
|
|
||||||
if hasRunSetup && !agentStatusChecker.running {
|
|
||||||
Text("Secret Agent Is Not Running")
|
|
||||||
} else {
|
|
||||||
Text("Setup Secretive")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
})
|
|
||||||
.background(Color.orange)
|
|
||||||
.cornerRadius(5)
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var appPathNotice: ToolbarItem<Void, AnyView> {
|
|
||||||
let controller = ApplicationDirectoryController()
|
|
||||||
guard !controller.isInApplicationsDirectory else {
|
|
||||||
return ToolbarItem { AnyView(EmptyView()) }
|
|
||||||
}
|
|
||||||
return ToolbarItem {
|
|
||||||
AnyView(
|
|
||||||
Button(action: {
|
|
||||||
showingAppPathNotice = true
|
|
||||||
}, label: {
|
|
||||||
Group {
|
|
||||||
Text("Secretive Is Not in Applications Folder")
|
|
||||||
}
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
})
|
})
|
||||||
.background(Color.orange)
|
.buttonStyle(ToolbarButtonStyle(color: color))
|
||||||
.cornerRadius(5)
|
.popover(item: $selectedUpdate, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) { update in
|
||||||
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: .point(.bottom), arrowEdge: .bottom) {
|
UpdateDetailView(update: update)
|
||||||
VStack {
|
}
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(width: 64)
|
|
||||||
Text("Secretive needs to be in your Applications folder to work properly. Please move it and relaunch.")
|
|
||||||
.frame(maxWidth: 300)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var newItemView: some View {
|
||||||
|
if storeList.modifiableStore?.isAvailable ?? false {
|
||||||
|
Button(action: {
|
||||||
|
showingCreation = true
|
||||||
|
}, label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
})
|
||||||
|
.sheet(isPresented: $showingCreation) {
|
||||||
|
if let modifiable = storeList.modifiableStore {
|
||||||
|
CreateSecretView(store: modifiable, showing: $showingCreation)
|
||||||
|
.onDisappear {
|
||||||
|
guard let newest = modifiable.secrets.last?.id else { return }
|
||||||
|
activeSecret = newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var setupNoticeView: some View {
|
||||||
|
Button(action: {
|
||||||
|
runningSetup = true
|
||||||
|
}, label: {
|
||||||
|
Group {
|
||||||
|
if hasRunSetup && !agentStatusChecker.running {
|
||||||
|
Text("Secret Agent Is Not Running")
|
||||||
|
} else {
|
||||||
|
Text("Setup Secretive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
})
|
||||||
|
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var runningNoticeView: some View {
|
||||||
|
Button(action: {
|
||||||
|
showingAgentInfo = true
|
||||||
|
}, label: {
|
||||||
|
HStack {
|
||||||
|
Text("Agent is Running")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(colorScheme == .light ? Color(white: 0.3) : .white)
|
||||||
|
Circle()
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
.foregroundColor(Color.green)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buttonStyle(ToolbarButtonStyle(lightColor: .black.opacity(0.05), darkColor: .white.opacity(0.05)))
|
||||||
|
.popover(isPresented: $showingAgentInfo, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
||||||
|
VStack {
|
||||||
|
Text("SecretAgent is Running")
|
||||||
|
.font(.title)
|
||||||
|
.padding(5)
|
||||||
|
Text("SecretAgent is a process that runs in the background to sign requests, so you don't need to keep Secretive open all the time.\n\n**You can close Secretive, and everything will still keep working.**")
|
||||||
|
.frame(width: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var appPathNoticeView: some View {
|
||||||
|
if !ApplicationDirectoryController().isInApplicationsDirectory {
|
||||||
|
Button(action: {
|
||||||
|
showingAppPathNotice = true
|
||||||
|
}, label: {
|
||||||
|
Group {
|
||||||
|
Text("Secretive Is Not in Applications Folder")
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
})
|
||||||
|
.buttonStyle(ToolbarButtonStyle(color: .orange))
|
||||||
|
.popover(isPresented: $showingAppPathNotice, attachmentAnchor: attachmentAnchor, arrowEdge: .bottom) {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 64)
|
||||||
|
Text("Secretive needs to be in your Applications folder to work properly. Please move it and relaunch.")
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentAnchor: PopoverAttachmentAnchor {
|
||||||
|
// Ideally .point(.bottom), but broken on Sonoma (FB12726503)
|
||||||
|
.rect(.bounds)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@ -198,3 +230,4 @@ struct ContentView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -35,7 +35,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -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 only supports Elliptic Curve keys.")
|
Text("Secretive supports EC256, EC384, RSA1024, and RSA2048 keys.")
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,8 @@ 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")
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
|
||||||
struct StoreListView: View {
|
struct StoreListView: View {
|
||||||
|
|
||||||
@Binding var showingCreation: Bool
|
@Binding var activeSecret: AnySecret.ID?
|
||||||
|
|
||||||
@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) {
|
||||||
@ -14,7 +13,7 @@ struct StoreListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func secretRenamed(secret: AnySecret) {
|
private func secretRenamed(secret: AnySecret) {
|
||||||
activeSecret = nextDefaultSecret
|
activeSecret = secret.id
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal file
37
Sources/Secretive/Views/ToolbarButtonStyle.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ToolbarButtonStyle: ButtonStyle {
|
||||||
|
|
||||||
|
private let lightColor: Color
|
||||||
|
private let darkColor: Color
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@State var hovering = false
|
||||||
|
|
||||||
|
init(color: Color) {
|
||||||
|
self.lightColor = color
|
||||||
|
self.darkColor = color
|
||||||
|
}
|
||||||
|
|
||||||
|
init(lightColor: Color, darkColor: Color) {
|
||||||
|
self.lightColor = lightColor
|
||||||
|
self.darkColor = darkColor
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.padding(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
|
||||||
|
.background(colorScheme == .light ? lightColor : darkColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.stroke(colorScheme == .light ? .black.opacity(0.15) : .white.opacity(0.15), lineWidth: 1)
|
||||||
|
.background(hovering ? (colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.05)) : Color.clear)
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation {
|
||||||
|
self.hovering = hovering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user