mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-05-08 16:38:58 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0f6b2924 | ||
|
|
fd95771fed | ||
|
|
267fe4bf27 | ||
|
|
a0cf74f9dc | ||
|
|
940b6b1b86 |
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- if: matrix.build-mode == 'manual'
|
||||
name: "Select Xcode"
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- if: matrix.build-mode == 'manual'
|
||||
name: "Build"
|
||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme Secretive CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -25,7 +25,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_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
|
||||
2
.github/workflows/oneoff.yml
vendored
2
.github/workflows/oneoff.yml
vendored
@@ -24,7 +24,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_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -22,7 +22,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_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- name: Test
|
||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||
# SPM doesn't seem to pick up on the tests currently?
|
||||
@@ -47,7 +47,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_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- name: Update Build Number
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set Environment
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.4.app
|
||||
run: sudo xcrun xcode-select -s /Applications/Xcode_26.2.app
|
||||
- name: Test Main Packages
|
||||
run: xcrun xcodebuild -project Sources/Secretive.xcodeproj -scheme PackageTests test
|
||||
# SPM doesn't seem to pick up on the tests currently?
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -93,7 +93,3 @@ iOSInjectionProject/
|
||||
Archive.xcarchive
|
||||
.DS_Store
|
||||
contents.xcworkspacedata
|
||||
|
||||
# Per-User Configs
|
||||
|
||||
Sources/Config/OpenSource.xcconfig
|
||||
@@ -10,10 +10,6 @@ Security is obviously paramount for a project like Secretive. As such, any contr
|
||||
|
||||
Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected.
|
||||
|
||||
### AI/LLM Policy
|
||||
|
||||
For security and auditing reasons similar to the policy Secretive has on dependencies, any code generated with AI or LLM tools will not be accepted.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
@@ -22,15 +22,6 @@ let package = Package(
|
||||
.library(
|
||||
name: "SmartCardSecretKit",
|
||||
targets: ["SmartCardSecretKit"]),
|
||||
.library(
|
||||
name: "CertificateKit",
|
||||
targets: ["CertificateKit"]),
|
||||
.library(
|
||||
name: "SSHProtocolKit",
|
||||
targets: ["SSHProtocolKit"]),
|
||||
.library(
|
||||
name: "Formatters",
|
||||
targets: ["Formatters"]),
|
||||
],
|
||||
dependencies: [
|
||||
],
|
||||
@@ -62,33 +53,6 @@ let package = Package(
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings
|
||||
),
|
||||
.target(
|
||||
name: "CertificateKit",
|
||||
dependencies: ["SecretKit", "Formatters"],
|
||||
path: "Sources/Packages/Sources/CertificateKit",
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "SSHProtocolKit",
|
||||
dependencies: ["SecretKit", "CertificateKit"],
|
||||
path: "Sources/Packages/Sources/SSHProtocolKit",
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.testTarget(
|
||||
name: "SSHProtocolKitTests",
|
||||
dependencies: ["SSHProtocolKit"],
|
||||
path: "Sources/Packages/Tests/SSHProtocolKitTests",
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "Formatters",
|
||||
dependencies: [],
|
||||
path: "Sources/Packages/Sources/Formatters",
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
CI_VERSION = GITHUB_CI_VERSION
|
||||
CI_BUILD_NUMBER = GITHUB_BUILD_NUMBER
|
||||
CI_BUILD_LINK = GITHUB_BUILD_URL
|
||||
|
||||
#include? "OpenSource.xcconfig"
|
||||
|
||||
SECRETIVE_BASE_BUNDLE_ID = $(SECRETIVE_BASE_BUNDLE_ID_OSS:default=com.maxgoedjen.Secretive)
|
||||
SECRETIVE_DEVELOPMENT_TEAM = $(SECRETIVE_DEVELOPMENT_TEAM_OSS:default=Z72PRUAWF6)
|
||||
|
||||
@@ -25,15 +25,9 @@ let package = Package(
|
||||
.library(
|
||||
name: "SecretAgentKit",
|
||||
targets: ["SecretAgentKit"]),
|
||||
.library(
|
||||
name: "Formatters",
|
||||
targets: ["Formatters"]),
|
||||
.library(
|
||||
name: "Common",
|
||||
targets: ["Common"]),
|
||||
.library(
|
||||
name: "SharedXPCServices",
|
||||
targets: ["SharedXPCServices"]),
|
||||
.library(
|
||||
name: "Brief",
|
||||
targets: ["Brief"]),
|
||||
@@ -55,7 +49,7 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "SecretKitTests",
|
||||
dependencies: ["SecretKit", "SecretAgentKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||
dependencies: ["SecretKit", "SecureEnclaveSecretKit", "SmartCardSecretKit"],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
@@ -72,13 +66,13 @@ let package = Package(
|
||||
),
|
||||
.target(
|
||||
name: "CertificateKit",
|
||||
dependencies: ["SecretKit", "Formatters"],
|
||||
dependencies: ["SecretKit", "SSHProtocolKit"],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
// swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "SecretAgentKit",
|
||||
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
|
||||
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit"],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
@@ -88,30 +82,17 @@ let package = Package(
|
||||
),
|
||||
.target(
|
||||
name: "SSHProtocolKit",
|
||||
dependencies: ["SecretKit", "CertificateKit"],
|
||||
dependencies: ["SecretKit"],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
// swiftSettings: swiftSettings,
|
||||
),
|
||||
.testTarget(
|
||||
name: "SSHProtocolKitTests",
|
||||
dependencies: ["SSHProtocolKit"],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "Formatters",
|
||||
dependencies: [],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "Common",
|
||||
dependencies: ["SSHProtocolKit", "SecretKit"],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
.target(
|
||||
name: "SharedXPCServices",
|
||||
dependencies: ["CertificateKit", "SSHProtocolKit"],
|
||||
dependencies: [],
|
||||
resources: [localization],
|
||||
swiftSettings: swiftSettings,
|
||||
),
|
||||
|
||||
@@ -5547,130 +5547,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_critical_options_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Critical Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_extensions_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Extensions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_key_id_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Key ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_path_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Certificate Path"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_principals_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Principals"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_serial_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Serial Number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_sha256_public_key_fingerprint_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Public Key Fingerprint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_sha256_signing_key_fingerprint_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Signing CA Fingerprint"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_valid_after_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Valid After"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_valid_until_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Valid Until"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"certificate_detail_validity_range_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Validity Range"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Certificates" : {
|
||||
|
||||
},
|
||||
"copyable_click_to_copy_button" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -10118,181 +9994,181 @@
|
||||
"af" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"ca" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Esborrar %1$(name)@?"
|
||||
"value" : "Esborrar %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$(name)@ Löschen?"
|
||||
"value" : "%1$(secretName)@ Löschen?"
|
||||
}
|
||||
},
|
||||
"el" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"fi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Poista %1$(name)@?"
|
||||
"value" : "Poista %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Supprimer %1$(name)@?"
|
||||
"value" : "Supprimer %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Eliminare %1$(name)@?"
|
||||
"value" : "Eliminare %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$(name)@を削除しますか?"
|
||||
"value" : "%1$(secretName)@を削除しますか?"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$(name)@를 지우겠습니까?"
|
||||
"value" : "%1$(secretName)@를 지우겠습니까?"
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Usunąć %1$(name)@?"
|
||||
"value" : "Usunąć %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Deletar %1$(name)@?"
|
||||
"value" : "Deletar %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"ro" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Удалить %1$(name)@?"
|
||||
"value" : "Удалить %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Delete %1$(name)@?"
|
||||
"value" : "Delete %1$(secretName)@?"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "删除“%1$(name)@”吗?"
|
||||
"value" : "删除“%1$(secretName)@”吗?"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "刪除「%1$(name)@」嗎?"
|
||||
"value" : "刪除「%1$(secretName)@」嗎?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19761,28 +19637,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"rename_certificate_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rename_certificate_name_placeholder" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Certificate Name"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reveal_in_finder_button" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -19968,17 +19822,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"secret_detail_certificate_path_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Matching Certificates"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"secret_detail_md5_fingerprint_label" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -75,14 +75,14 @@ extension Updater {
|
||||
.reversed()
|
||||
.filter({ !$0.prerelease })
|
||||
.first(where: { $0.minimumOSVersion <= osVersion }) else { return }
|
||||
guard !userIgnored(release: release) else { return }
|
||||
guard !release.prerelease else { return }
|
||||
let latestVersion = SemVer(release.name)
|
||||
if latestVersion > currentVersion {
|
||||
// guard !userIgnored(release: release) else { return }
|
||||
// guard !release.prerelease else { return }
|
||||
// let latestVersion = SemVer(release.name)
|
||||
// if latestVersion > currentVersion {
|
||||
await MainActor.run {
|
||||
state.update = release
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
/// Checks whether the user has ignored a release.
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Formatters
|
||||
//import SecretKit
|
||||
|
||||
@dynamicMemberLookup
|
||||
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
|
||||
//extension SecureEnclave {
|
||||
|
||||
public var openSSHCertificate: OpenSSHCertificate
|
||||
public let rawData: Data
|
||||
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable {
|
||||
|
||||
public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) {
|
||||
self.openSSHCertificate = openSSHCertificate
|
||||
self.rawData = rawData
|
||||
}
|
||||
|
||||
public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) }
|
||||
|
||||
public var debugDescription: String { openSSHCertificate.debugDescription }
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<OpenSSHCertificate, T>) -> T {
|
||||
openSSHCertificate[keyPath: keyPath]
|
||||
}
|
||||
public var id: Int { hashValue }
|
||||
public var type: String
|
||||
public let name: String?
|
||||
public let data: Data
|
||||
|
||||
}
|
||||
|
||||
//}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
import CryptoKit
|
||||
import os
|
||||
import SSHProtocolKit
|
||||
|
||||
public struct CertificateKitMigrator {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
|
||||
let directory: URL
|
||||
|
||||
/// Initializes a PublicKeyFileStoreController.
|
||||
public init(homeDirectory: URL) {
|
||||
directory = homeDirectory.appending(component: "PublicKeys")
|
||||
}
|
||||
|
||||
@MainActor public func migrate() throws {
|
||||
let fileCerts = try FileManager.default
|
||||
.contentsOfDirectory(atPath: directory.path())
|
||||
.filter { $0.hasSuffix("-cert.pub") }
|
||||
Task {
|
||||
for path in fileCerts {
|
||||
let url = directory.appending(component: path)
|
||||
let data = try! Data(contentsOf: url)
|
||||
// let parser = try! await XPCCertificateParser()
|
||||
let parser = OpenSSHCertificateParser()
|
||||
let cert = try! await parser.parse(data: data)
|
||||
print(cert)
|
||||
// let secret = storeList.allSecrets.first { secret in
|
||||
// secret.name == cert.name
|
||||
// }
|
||||
// guard let secret = secret ?? storeList.allSecrets.first else { return }
|
||||
// print(cert.data.formatted(.hex()))
|
||||
// certificateStore.saveCertificate(cert.data, for: secret)
|
||||
print(cert)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// let privateAttributes = KeychainDictionary([
|
||||
// kSecClass: kSecClassKey,
|
||||
// kSecAttrKeyType: Constants.oldKeyType,
|
||||
// kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
|
||||
// kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||
// kSecReturnRef: true,
|
||||
// kSecMatchLimit: kSecMatchLimitAll,
|
||||
// kSecReturnAttributes: true
|
||||
// ])
|
||||
// var privateUntyped: CFTypeRef?
|
||||
// unsafe SecItemCopyMatching(privateAttributes, &privateUntyped)
|
||||
// guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
||||
// let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
||||
// var migratedAny = false
|
||||
// for key in privateTyped {
|
||||
// let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||
// let id = key[kSecAttrApplicationLabel] as! Data
|
||||
// guard !id.contains(Constants.migrationMagicNumber) else {
|
||||
// logger.log("Skipping \(name), already migrated.")
|
||||
// continue
|
||||
// }
|
||||
// let ref = key[kSecValueRef] as! SecKey
|
||||
// let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
|
||||
// let tokenObjectID = unsafe attributes[Constants.tokenObjectID] as! Data
|
||||
// let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
||||
// // Best guess.
|
||||
// let auth: AuthenticationRequirement = String(describing: accessControl)
|
||||
// .contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
||||
// do {
|
||||
// let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
||||
// let secret = Secret(id: UUID().uuidString, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
|
||||
// guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
|
||||
// logger.log("Skipping \(name), public key already present. Marking as migrated.")
|
||||
// markMigrated(secret: secret, oldID: id)
|
||||
// continue
|
||||
// }
|
||||
// logger.log("Migrating \(name).")
|
||||
// try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||
// logger.log("Migrated \(name).")
|
||||
// markMigrated(secret: secret, oldID: id)
|
||||
// migratedAny = true
|
||||
// } catch {
|
||||
// logger.error("Failed to migrate \(name): \(error.localizedDescription).")
|
||||
// }
|
||||
// }
|
||||
// if migratedAny {
|
||||
// store.reloadSecrets()
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,86 +4,41 @@ import Security
|
||||
import os
|
||||
import SecretKit
|
||||
|
||||
@Observable @MainActor public final class CertificateStore: Sendable {
|
||||
@Observable public final class CertificateStore {
|
||||
|
||||
public private(set) var certificates: [Certificate] = []
|
||||
@MainActor private var certificates: [Certificate] = []
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
@MainActor public init() {
|
||||
loadCertificates()
|
||||
Task {
|
||||
for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) {
|
||||
guard Constants.notificationToken != (note.object as? String) else {
|
||||
// Don't reload if we're the ones triggering this by reloading.
|
||||
continue
|
||||
}
|
||||
loadCertificates()
|
||||
}
|
||||
// for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) {
|
||||
// guard Constants.notificationToken != (note.object as? String) else {
|
||||
// // Don't reload if we're the ones triggering this by reloading.
|
||||
// continue
|
||||
// }
|
||||
// loadCertificates()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
public func reloadCertificates() {
|
||||
@MainActor public func reloadCertificates() {
|
||||
let before = certificates
|
||||
certificates.removeAll()
|
||||
loadCertificates()
|
||||
if certificates != before {
|
||||
NotificationCenter.default.post(name: .certificateStoreReloaded, object: self)
|
||||
DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
|
||||
// NotificationCenter.default.post(name: .certificateStoreReloaded, object: self)
|
||||
// DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func save(certificate: Certificate) throws {
|
||||
let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
|
||||
let keychainAttributes = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrService: Constants.keyTag,
|
||||
kSecAttrAccount: certificate.id,
|
||||
kSecUseDataProtectionKeychain: true,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecValueData: certificate.rawData,
|
||||
kSecAttrGeneric: attributes
|
||||
])
|
||||
let status = SecItemAdd(keychainAttributes, nil)
|
||||
if status != errSecSuccess && status != errSecDuplicateItem {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
reloadCertificates()
|
||||
@MainActor public func saveCertificate(_ data: Data, for secret: any Secret) {
|
||||
let certificate = SecCertificateCreateWithData(nil, data as CFData)
|
||||
print(certificate as Any)
|
||||
}
|
||||
|
||||
public func delete(certificate: Certificate) throws {
|
||||
let deleteAttributes = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrService: Constants.keyTag,
|
||||
kSecUseDataProtectionKeychain: true,
|
||||
kSecAttrAccount: certificate.id,
|
||||
])
|
||||
let status = SecItemDelete(deleteAttributes)
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
reloadCertificates()
|
||||
}
|
||||
|
||||
public func update(certificate: Certificate) throws {
|
||||
let updateQuery = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrAccount: certificate.id,
|
||||
])
|
||||
|
||||
let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
|
||||
let updatedAttributes = KeychainDictionary([
|
||||
kSecAttrGeneric: cert,
|
||||
])
|
||||
|
||||
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError(statusCode: status)
|
||||
}
|
||||
reloadCertificates()
|
||||
}
|
||||
|
||||
public func certificates(for secret: any Secret) -> [Certificate] {
|
||||
certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
|
||||
@MainActor public func certificates(for secret: any Secret) -> [Certificate] {
|
||||
[]
|
||||
}
|
||||
|
||||
|
||||
@@ -92,62 +47,88 @@ import SecretKit
|
||||
extension CertificateStore {
|
||||
|
||||
/// Loads all certificates from the store.
|
||||
private func loadCertificates() {
|
||||
let queryAttributes = KeychainDictionary([
|
||||
kSecClass: Constants.keyClass,
|
||||
kSecAttrService: Constants.keyTag,
|
||||
kSecUseDataProtectionKeychain: true,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitAll,
|
||||
kSecReturnAttributes: true
|
||||
])
|
||||
var untyped: CFTypeRef?
|
||||
unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||
let decoder = JSONDecoder()
|
||||
let wrapped: [Certificate] = typed.compactMap {
|
||||
do {
|
||||
guard let data = $0[kSecValueData] as? Data,
|
||||
let attributesData = $0[kSecAttrGeneric] as? Data else {
|
||||
throw MissingAttributesError()
|
||||
}
|
||||
return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.filter {
|
||||
if let validityRange = $0.validityRange {
|
||||
validityRange.contains(Date())
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
certificates.append(contentsOf: wrapped)
|
||||
@MainActor private func loadCertificates() {
|
||||
// let queryAttributes = KeychainDictionary([
|
||||
// kSecClass: Constants.keyClass,
|
||||
// kSecAttrService: Constants.keyTag,
|
||||
// kSecUseDataProtectionKeychain: true,
|
||||
// kSecReturnData: true,
|
||||
// kSecMatchLimit: kSecMatchLimitAll,
|
||||
// kSecReturnAttributes: true
|
||||
// ])
|
||||
// var untyped: CFTypeRef?
|
||||
// unsafe SecItemCopyMatching(queryAttributes, &untyped)
|
||||
// guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||
// let wrapped: [SecureEnclave.Certificates] = typed.compactMap {
|
||||
// do {
|
||||
// let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_certificate")
|
||||
// guard let attributesData = $0[kSecAttrGeneric] as? Data,
|
||||
// let id = $0[kSecAttrAccount] as? String else {
|
||||
// throw MissingAttributesError()
|
||||
// }
|
||||
// let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||
// let keyData = $0[kSecValueData] as! Data
|
||||
// let publicKey: Data
|
||||
// switch attributes.keyType {
|
||||
// case .ecdsa256:
|
||||
// let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||
// publicKey = key.publicKey.x963Representation
|
||||
// case .mldsa65:
|
||||
// guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||
// let key = try CryptoKit.SecureEnclave.MLDSA65.PrivateKey(dataRepresentation: keyData)
|
||||
// publicKey = key.publicKey.rawRepresentation
|
||||
// case .mldsa87:
|
||||
// guard #available(macOS 26.0, *) else { throw UnsupportedAlgorithmError() }
|
||||
// let key = try CryptoKit.SecureEnclave.MLDSA87.PrivateKey(dataRepresentation: keyData)
|
||||
// publicKey = key.publicKey.rawRepresentation
|
||||
// default:
|
||||
// throw UnsupportedAlgorithmError()
|
||||
// }
|
||||
// return SecureEnclave.Certificates(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||
// } catch {
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
// certificates.append(contentsOf: wrapped)
|
||||
}
|
||||
|
||||
/// Saves a public key.
|
||||
/// - Parameters:
|
||||
/// - key: The data representation key to save.
|
||||
/// - name: A user-facing name for the key.
|
||||
/// - attributes: Attributes of the key.
|
||||
/// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
|
||||
// @discardableResult
|
||||
// func saveKey(_ key: Data, name: String, attributes: Attributes) throws -> String {
|
||||
// let attributes = try JSONEncoder().encode(attributes)
|
||||
// let id = UUID().uuidString
|
||||
// let keychainAttributes = KeychainDictionary([
|
||||
// kSecClass: Constants.keyClass,
|
||||
// kSecAttrService: Constants.keyTag,
|
||||
// kSecUseDataProtectionKeychain: true,
|
||||
// kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
// kSecAttrAccount: id,
|
||||
// kSecValueData: key,
|
||||
// kSecAttrLabel: name,
|
||||
// kSecAttrGeneric: attributes
|
||||
// ])
|
||||
// let status = SecItemAdd(keychainAttributes, nil)
|
||||
// if status != errSecSuccess {
|
||||
// throw KeychainError(statusCode: status)
|
||||
// }
|
||||
// return id
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
extension CertificateStore {
|
||||
|
||||
enum Constants {
|
||||
static let keyClass = kSecClassGenericPassword as String
|
||||
static let keyTag = Data("com.maxgoedjen.certificatestore.opensshcertificate".utf8)
|
||||
static let keyClass = kSecClassCertificate as String
|
||||
static let keyTag = Data("com.maxgoedjen.certificateive.certificate".utf8)
|
||||
static let notificationToken = UUID().uuidString
|
||||
}
|
||||
|
||||
struct UnsupportedAlgorithmError: Error {}
|
||||
struct MissingAttributesError: Error {}
|
||||
|
||||
}
|
||||
|
||||
extension NSNotification.Name {
|
||||
|
||||
// Distributed notification that keys were modified out of process (ie, that the management tool added/removed certificates)
|
||||
public static let certificateStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.updated")
|
||||
// Internal notification that certificates were reloaded from the backing store.
|
||||
public static let certificateStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.reloaded")
|
||||
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import Foundation
|
||||
import Formatters
|
||||
|
||||
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible {
|
||||
|
||||
public var type: CertificateType
|
||||
public var name: String
|
||||
public var data: Data
|
||||
|
||||
public var publicKey: PublicKey
|
||||
public var principals: [String]
|
||||
public var keyID: String
|
||||
public var serial: UInt64
|
||||
public var validityRange: Range<Date>?
|
||||
public var criticalOptions: [String]
|
||||
public var extensions: [String]
|
||||
public var signingKey: PublicKey
|
||||
|
||||
public init(
|
||||
type: OpenSSHCertificate.CertificateType,
|
||||
name: String,
|
||||
data: Data,
|
||||
publicKey: PublicKey,
|
||||
principals: [String],
|
||||
keyID: String,
|
||||
serial: UInt64,
|
||||
validityRange: Range<Date>? = nil,
|
||||
criticalOptions: [String],
|
||||
extensions: [String],
|
||||
signingKey: PublicKey,
|
||||
) {
|
||||
self.type = type
|
||||
self.name = name
|
||||
self.data = data
|
||||
self.publicKey = publicKey
|
||||
self.principals = principals
|
||||
self.keyID = keyID
|
||||
self.serial = serial
|
||||
self.validityRange = validityRange
|
||||
self.criticalOptions = criticalOptions
|
||||
self.extensions = extensions
|
||||
self.signingKey = signingKey
|
||||
}
|
||||
|
||||
public var debugDescription: String {
|
||||
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension OpenSSHCertificate {
|
||||
|
||||
public enum CertificateType: String, Sendable, Codable {
|
||||
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
|
||||
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
|
||||
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
|
||||
|
||||
public var keyIdentifier: String {
|
||||
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension OpenSSHCertificate {
|
||||
|
||||
public struct PublicKey: Hashable, Sendable, Codable {
|
||||
|
||||
public let keyType: String
|
||||
public let curveName: String
|
||||
public let data: Data
|
||||
|
||||
public init(keyType: String, curveName: String, data: Data) {
|
||||
self.keyType = keyType
|
||||
self.curveName = curveName
|
||||
self.data = data
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import Foundation
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
import SecretKit
|
||||
|
||||
extension URL {
|
||||
|
||||
@@ -17,32 +14,6 @@ extension URL {
|
||||
#endif
|
||||
}
|
||||
|
||||
public static var publicKeyDirectory: URL {
|
||||
agentHomeURL.appending(component: "PublicKeys")
|
||||
}
|
||||
|
||||
public static var certificatesDirectory: URL {
|
||||
agentHomeURL.appending(component: "Certificates")
|
||||
}
|
||||
|
||||
/// The path for a Secret's public key.
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - Returns: The path to the Secret's public key.
|
||||
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||
public static func publicKeyPath<SecretType: Secret>(for secret: SecretType, in directory: URL) -> String {
|
||||
let keyWriter = OpenSSHPublicKeyWriter()
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
return directory.appending(component: "\(minimalHex).pub").path()
|
||||
}
|
||||
|
||||
/// The path for a certificate.
|
||||
/// - Parameter certificate: The Certificate to return the path for.
|
||||
/// - Returns: The path to the Certificate.
|
||||
/// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to.
|
||||
public static func certificatePath(for certificateID: String, in directory: URL) -> String {
|
||||
return directory.appending(component: "\(certificateID)-cert.pub").path()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension String {
|
||||
@@ -56,4 +27,3 @@ extension String {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
|
||||
|
||||
let separator: String
|
||||
|
||||
public init(separator: String) {
|
||||
self.separator = separator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
||||
|
||||
public func format(_ value: SequenceType) -> String {
|
||||
value
|
||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
||||
.joined(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == HexDataStyle<Data> {
|
||||
|
||||
public static func hex(separator: String = "") -> HexDataStyle<Data> {
|
||||
HexDataStyle(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
||||
|
||||
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
|
||||
HexDataStyle(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public struct Base64DataStyle<SequenceType: Sequence>: Hashable, Codable {
|
||||
|
||||
private let stripPadding: Bool
|
||||
|
||||
public init(stripPadding: Bool) {
|
||||
self.stripPadding = stripPadding
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Base64DataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
||||
|
||||
public func format(_ value: SequenceType) -> String {
|
||||
let base64 = Data(value).base64EncodedString()
|
||||
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
||||
return base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Base64DataStyle<Data> {
|
||||
|
||||
public static func base64(stripPadding: Bool) -> Base64DataStyle<Data> {
|
||||
Base64DataStyle(stripPadding: stripPadding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Base64DataStyle<SHA256.Digest> {
|
||||
|
||||
public static func base64(stripPadding: Bool) -> Base64DataStyle<SHA256.Digest> {
|
||||
Base64DataStyle(stripPadding: stripPadding)
|
||||
}
|
||||
|
||||
}
|
||||
37
Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift
Normal file
37
Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct HexDataStyle<SequenceType: Sequence>: Hashable, Codable {
|
||||
|
||||
let separator: String
|
||||
|
||||
public init(separator: String) {
|
||||
self.separator = separator
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension HexDataStyle: FormatStyle where SequenceType.Element == UInt8 {
|
||||
|
||||
public func format(_ value: SequenceType) -> String {
|
||||
value
|
||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
||||
.joined(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == HexDataStyle<Data> {
|
||||
|
||||
public static func hex(separator: String = "") -> HexDataStyle<Data> {
|
||||
HexDataStyle(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
||||
|
||||
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
|
||||
HexDataStyle(separator: separator)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CertificateKit
|
||||
import Formatters
|
||||
|
||||
/// Generates OpenSSH representations of Certificates.
|
||||
public struct OpenSSHCertificateWriter: Sendable {
|
||||
|
||||
/// Initializes the writer.
|
||||
public init() {
|
||||
}
|
||||
|
||||
/// Generates an OpenSSH data payload identifying the certificate.
|
||||
/// - Returns: OpenSSH data payload identifying the certificate.
|
||||
public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data {
|
||||
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||
publicKey.keyType.lengthAndData +
|
||||
publicKey.curveName.lengthAndData +
|
||||
publicKey.data.lengthAndData
|
||||
}
|
||||
|
||||
/// Generates an OpenSSH SHA256 fingerprint string.
|
||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||
public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String {
|
||||
// OpenSSL format seems to strip the padding at the end.
|
||||
let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true))
|
||||
return "SHA256:\(cleaned)"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,39 @@
|
||||
import Foundation
|
||||
import CertificateKit
|
||||
import OSLog
|
||||
import CryptoKit
|
||||
|
||||
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
|
||||
|
||||
public var id: Int { hashValue }
|
||||
public var type: CertificateType
|
||||
public let name: String?
|
||||
public let data: Data
|
||||
|
||||
public var publicKey: Data
|
||||
public var principals: [String]
|
||||
public var keyID: String
|
||||
public var serial: UInt64
|
||||
public var validityRange: Range<Date>?
|
||||
|
||||
public var debugDescription: String {
|
||||
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension OpenSSHCertificate {
|
||||
|
||||
public enum CertificateType: String, Sendable, Codable {
|
||||
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
|
||||
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
|
||||
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
|
||||
|
||||
var keyIdentifier: String {
|
||||
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public protocol OpenSSHCertificateParserProtocol {
|
||||
func parse(data: Data) async throws -> OpenSSHCertificate
|
||||
@@ -7,8 +41,9 @@ public protocol OpenSSHCertificateParserProtocol {
|
||||
|
||||
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "OpenSSHCertificateParser")
|
||||
|
||||
public init() {
|
||||
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
|
||||
}
|
||||
|
||||
public func parse(data: Data) throws(OpenSSHCertificateError) -> OpenSSHCertificate {
|
||||
@@ -25,18 +60,15 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
|
||||
guard let decoded = Data(base64Encoded: encodedKey) else {
|
||||
throw OpenSSHCertificateError.parsingFailed
|
||||
}
|
||||
let comment = elements.first
|
||||
let name = elements.first
|
||||
do {
|
||||
let dataParser = OpenSSHReader(data: decoded)
|
||||
let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but
|
||||
.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||
_ = try dataParser.readNextChunkAsString() // Redundant key type
|
||||
_ = try dataParser.readNextChunk() // Nonce
|
||||
let publicKeyCurveName = try dataParser.readNextChunkAsString()
|
||||
let publicKeyData = try dataParser.readNextChunk()
|
||||
let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData)
|
||||
_ = try dataParser.readNextChunkAsString() // curve
|
||||
let publicKey = try dataParser.readNextChunk()
|
||||
let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
|
||||
_ = role
|
||||
let keyIdentifier = try dataParser.readNextChunkAsString()
|
||||
let principalsReader = try dataParser.readNextChunkAsSubReader()
|
||||
var principals: [String] = []
|
||||
@@ -46,41 +78,16 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
|
||||
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||
let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
|
||||
let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
|
||||
let criticalOptionsReader = try dataParser.readNextChunkAsSubReader()
|
||||
var criticalOptions: [String] = []
|
||||
while !criticalOptionsReader.done {
|
||||
let next = try criticalOptionsReader.readNextChunkAsString()
|
||||
if !next.isEmpty {
|
||||
criticalOptions.append(next)
|
||||
}
|
||||
}
|
||||
let extensionsReader = try dataParser.readNextChunkAsSubReader()
|
||||
var extensions: [String] = []
|
||||
while !extensionsReader.done {
|
||||
let next = try extensionsReader.readNextChunkAsString()
|
||||
if !next.isEmpty {
|
||||
extensions.append(next)
|
||||
}
|
||||
}
|
||||
_ = try dataParser.readNextChunk() // reserved
|
||||
let signingKeyReader = try dataParser.readNextChunkAsSubReader()
|
||||
let signingKeyType = try signingKeyReader.readNextChunkAsString()
|
||||
let signingKeyCurveName = try signingKeyReader.readNextChunkAsString()
|
||||
let signingKeyData = try signingKeyReader.readNextChunk()
|
||||
let signingKey = OpenSSHCertificate.PublicKey(keyType: signingKeyType, curveName: signingKeyCurveName, data: signingKeyData)
|
||||
|
||||
return OpenSSHCertificate(
|
||||
type: type,
|
||||
name: comment ?? keyIdentifier,
|
||||
data: decoded,
|
||||
name: name,
|
||||
data: data,
|
||||
publicKey: publicKey,
|
||||
principals: principals,
|
||||
keyID: keyIdentifier,
|
||||
serial: serialNumber,
|
||||
validityRange: validityRange,
|
||||
criticalOptions: criticalOptions,
|
||||
extensions: extensions,
|
||||
signingKey: signingKey,
|
||||
validityRange: validityRange
|
||||
)
|
||||
} catch {
|
||||
throw .parsingFailed
|
||||
@@ -41,7 +41,9 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||
// OpenSSL format seems to strip the padding at the end.
|
||||
let cleaned = SHA256.hash(data: data(secret: secret)).formatted(.base64(stripPadding: true))
|
||||
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
|
||||
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
|
||||
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||
return "SHA256:\(cleaned)"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// Reads OpenSSH protocol data.
|
||||
public final class OpenSSHReader {
|
||||
final class OpenSSHReader {
|
||||
|
||||
var remaining: Data
|
||||
var done = false
|
||||
|
||||
/// Initialize the reader with an OpenSSH data payload.
|
||||
/// - Parameter data: The data to read.
|
||||
public init(data: Data) {
|
||||
init(data: Data) {
|
||||
remaining = Data(data)
|
||||
if remaining.count == 0 {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the next chunk of data from the playload.
|
||||
/// - Returns: The next chunk of data.
|
||||
public func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
||||
func readNextChunk(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> Data {
|
||||
let length = try readNextBytes(as: UInt32.self, convertEndianness: convertEndianness)
|
||||
guard remaining.count >= length else { throw .beyondBounds }
|
||||
let dataRange = 0..<Int(length)
|
||||
@@ -29,7 +26,7 @@ public final class OpenSSHReader {
|
||||
return ret
|
||||
}
|
||||
|
||||
public func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
|
||||
func readNextBytes<T: FixedWidthInteger>(as: T.Type, convertEndianness: Bool = true) throws(OpenSSHReaderError) -> T {
|
||||
let size = MemoryLayout<T>.size
|
||||
guard remaining.count >= size else { throw .beyondBounds }
|
||||
let lengthRange = 0..<size
|
||||
@@ -42,11 +39,11 @@ public final class OpenSSHReader {
|
||||
return convertEndianness ? T(value.bigEndian) : T(value)
|
||||
}
|
||||
|
||||
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
||||
func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
|
||||
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
|
||||
}
|
||||
|
||||
public func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
||||
func readNextChunkAsSubReader(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> OpenSSHReader {
|
||||
OpenSSHReader(data: try readNextChunk(convertEndianness: convertEndianness))
|
||||
}
|
||||
|
||||
|
||||
@@ -30,28 +30,19 @@ public struct OpenSSHSignatureWriter: Sendable {
|
||||
|
||||
extension OpenSSHSignatureWriter {
|
||||
|
||||
/// Converts a fixed-width big-endian integer (e.g. r/s from CryptoKit rawRepresentation) into an SSH mpint.
|
||||
/// Strips unnecessary leading zeros and prefixes `0x00` if needed to keep the value positive.
|
||||
private func mpint(fromFixedWidthPositiveBytes bytes: Data) -> Data {
|
||||
// mpint zero is encoded as a string with zero bytes of data.
|
||||
guard let firstNonZeroIndex = bytes.firstIndex(where: { $0 != 0x00 }) else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
let trimmed = Data(bytes[firstNonZeroIndex...])
|
||||
|
||||
if let first = trimmed.first, first >= 0x80 {
|
||||
var prefixed = Data([0x00])
|
||||
prefixed.append(trimmed)
|
||||
return prefixed
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||
let rawLength = rawRepresentation.count/2
|
||||
let r = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[0..<rawLength]))
|
||||
let s = mpint(fromFixedWidthPositiveBytes: Data(rawRepresentation[rawLength...]))
|
||||
// Check if we need to pad with 0x00 to prevent certain
|
||||
// ssh servers from thinking r or s is negative
|
||||
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
|
||||
var r = Data(rawRepresentation[0..<rawLength])
|
||||
if paddingRange ~= r.first! {
|
||||
r.insert(0x00, at: 0)
|
||||
}
|
||||
var s = Data(rawRepresentation[rawLength...])
|
||||
if paddingRange ~= s.first! {
|
||||
s.insert(0x00, at: 0)
|
||||
}
|
||||
|
||||
var signatureChunk = Data()
|
||||
signatureChunk.append(r.lengthAndData)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
|
||||
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
|
||||
public final class PublicKeyFileStoreController: Sendable {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
||||
private let directory: URL
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
|
||||
/// Initializes a PublicKeyFileStoreController.
|
||||
public init(homeDirectory: URL) {
|
||||
directory = homeDirectory.appending(component: "PublicKeys")
|
||||
}
|
||||
|
||||
/// Writes out the keys specified to disk.
|
||||
/// - Parameter secrets: The Secrets to generate keys for.
|
||||
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||
logger.log("Writing public keys to disk")
|
||||
if clear {
|
||||
let validPaths = Set(secrets.map { publicKeyPath(for: $0) })
|
||||
.union(Set(secrets.map { sshCertificatePath(for: $0) }))
|
||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? []
|
||||
let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() }
|
||||
|
||||
let untracked = Set(fullPathContents)
|
||||
.subtracting(validPaths)
|
||||
for path in untracked {
|
||||
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||
}
|
||||
}
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil)
|
||||
for secret in secrets {
|
||||
let path = publicKeyPath(for: secret)
|
||||
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||
}
|
||||
logger.log("Finished writing public keys")
|
||||
}
|
||||
|
||||
/// The path for a Secret's public key.
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - Returns: The path to the Secret's public key.
|
||||
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
|
||||
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
return directory.appending(component: "\(minimalHex).pub").path()
|
||||
}
|
||||
|
||||
/// 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.path())
|
||||
.filter { $0.hasSuffix("-cert.pub") }
|
||||
.isEmpty == false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// The path for a Secret's SSH Certificate public key.
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - Returns: The path to the SSH Certificate public key.
|
||||
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
||||
public func sshCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
return directory.appending(component: "\(minimalHex)-cert.pub").path()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import CertificateKit
|
||||
|
||||
public protocol SSHAgentInputParserProtocol {
|
||||
|
||||
func parse(data: Data) async throws -> SSHAgent.Request
|
||||
|
||||
|
||||
}
|
||||
|
||||
public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||
@@ -14,7 +13,7 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
||||
|
||||
public init() {
|
||||
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
|
||||
|
||||
}
|
||||
|
||||
public func parse(data: Data) throws(AgentParsingError) -> SSHAgent.Request {
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
import CryptoKit
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import CertificateKit
|
||||
import AppKit
|
||||
import SSHProtocolKit
|
||||
|
||||
@@ -10,21 +9,23 @@ import SSHProtocolKit
|
||||
public final class Agent: Sendable {
|
||||
|
||||
private let storeList: SecretStoreList
|
||||
private let certificateStore: CertificateStore
|
||||
private let witness: SigningWitness?
|
||||
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||
private let signatureWriter = OpenSSHSignatureWriter()
|
||||
// private let certificateHandler = OpenSSHCertificateHandler()
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||
|
||||
/// Initializes an agent with a store list and a witness.
|
||||
/// - Parameters:
|
||||
/// - storeList: The `SecretStoreList` to make available.
|
||||
/// - witness: A witness to notify of requests.
|
||||
public init(storeList: SecretStoreList, certificateStore: CertificateStore, witness: SigningWitness? = nil) {
|
||||
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
|
||||
logger.debug("Agent is running")
|
||||
self.storeList = storeList
|
||||
self.certificateStore = certificateStore
|
||||
self.witness = witness
|
||||
Task { @MainActor in
|
||||
// await certificateHandler.reloadCertificates(for: storeList.allSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -67,6 +68,7 @@ extension Agent {
|
||||
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
||||
func identities() async -> Data {
|
||||
let secrets = await storeList.allSecrets
|
||||
// await certificateHandler.reloadCertificates(for: secrets)
|
||||
var count = 0
|
||||
var keyData = Data()
|
||||
|
||||
@@ -75,11 +77,12 @@ extension Agent {
|
||||
keyData.append(keyBlob.lengthAndData)
|
||||
keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
|
||||
count += 1
|
||||
for certificate in await certificateStore.certificates(for: secret) {
|
||||
keyData.append(certificate.data.lengthAndData)
|
||||
keyData.append(certificate.name.lengthAndData)
|
||||
count += 1
|
||||
}
|
||||
|
||||
// if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||
// keyData.append(certificateData.lengthAndData)
|
||||
// keyData.append(name.lengthAndData)
|
||||
// count += 1
|
||||
// }
|
||||
}
|
||||
logger.log("Agent enumerated \(count) identities")
|
||||
var countBigEndian = UInt32(count).bigEndian
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
|
||||
/// Manages storage and lookup for OpenSSH certificates.
|
||||
public actor OpenSSHCertificateHandler: Sendable {
|
||||
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||
private let writer = OpenSSHPublicKeyWriter()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
import Common
|
||||
|
||||
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
|
||||
public final class PublicKeyFileStoreController: Sendable {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
||||
private let publicKeysURL: URL
|
||||
private let certificatesURL: URL
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
|
||||
/// Initializes a PublicKeyFileStoreController.
|
||||
public init(publicKeysURL: URL, certificatesURL: URL) {
|
||||
self.publicKeysURL = publicKeysURL
|
||||
self.certificatesURL = certificatesURL
|
||||
}
|
||||
|
||||
/// Writes out the keys specified to disk.
|
||||
/// - Parameter secrets: The Secrets to generate keys for.
|
||||
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
|
||||
logger.log("Writing public keys to disk")
|
||||
if clear {
|
||||
let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: publicKeysURL) })
|
||||
.union(Set(secrets.map { legacySSHCertificatePath(for: $0) }))
|
||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: publicKeysURL.path())) ?? []
|
||||
let fullPathContents = contentsOfDirectory.map { publicKeysURL.appending(path: $0).path() }
|
||||
|
||||
let untracked = Set(fullPathContents)
|
||||
.subtracting(validPaths)
|
||||
for path in untracked {
|
||||
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||
}
|
||||
}
|
||||
try? FileManager.default.createDirectory(at: publicKeysURL, withIntermediateDirectories: false, attributes: nil)
|
||||
for secret in secrets {
|
||||
let path = URL.publicKeyPath(for: secret, in: publicKeysURL)
|
||||
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||
}
|
||||
logger.log("Finished writing public keys")
|
||||
}
|
||||
|
||||
/// Writes out the certificates specified to disk.
|
||||
/// - Parameter certificates: The Secrets to generate keys for.
|
||||
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
|
||||
public func generateCertificates(for certificates: [Certificate], clear: Bool = false) throws {
|
||||
logger.log("Writing certificates to disk")
|
||||
if clear {
|
||||
let validPaths = Set(certificates.map { URL.certificatePath(for: $0.id, in: certificatesURL) })
|
||||
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
|
||||
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() }
|
||||
|
||||
let untracked = Set(fullPathContents)
|
||||
.subtracting(validPaths)
|
||||
for path in untracked {
|
||||
// string instead of fileURLWithPath since we're already using fileURL format.
|
||||
try? FileManager.default.removeItem(at: URL(string: path)!)
|
||||
}
|
||||
}
|
||||
try? FileManager.default.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil)
|
||||
for certificate in certificates {
|
||||
let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
|
||||
FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
|
||||
}
|
||||
logger.log("Finished writing certificates")
|
||||
}
|
||||
|
||||
/// The path for a Secret's SSH Certificate public key.
|
||||
/// - Parameter secret: The Secret to return the path for.
|
||||
/// - Returns: The path to the SSH Certificate public key.
|
||||
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
|
||||
private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
|
||||
return publicKeysURL.appending(component: "\(minimalHex).pub").path()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import LocalAuthentication
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
package final class PersistentAuthenticationContext<SecretType: Secret>: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: SecretType
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
package nonisolated(unsafe) let context: LAContext
|
||||
/// An expiration date for the context.
|
||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||
let monotonicExpiration: UInt64
|
||||
|
||||
/// Initializes a context.
|
||||
/// - Parameters:
|
||||
/// - secret: The Secret to persist authentication for.
|
||||
/// - context: The LAContext used to authorize the persistent context.
|
||||
/// - duration: The duration of the authorization context, in seconds.
|
||||
init(secret: SecretType, context: LAContext, duration: TimeInterval) {
|
||||
self.secret = secret
|
||||
unsafe self.context = context
|
||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
package var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
package var expiration: Date {
|
||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
package actor PersistentAuthenticationHandler<SecretType: Secret>: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [SecretType: PersistentAuthenticationContext<SecretType>] = [:]
|
||||
|
||||
package init() {
|
||||
}
|
||||
|
||||
package func existingPersistedAuthenticationContext(secret: SecretType) -> PersistentAuthenticationContext<SecretType>? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
package func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
|
||||
let durationString = formatter.string(from: duration)!
|
||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
persistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import LocalAuthentication
|
||||
import SecretKit
|
||||
|
||||
extension SecureEnclave {
|
||||
|
||||
/// A context describing a persisted authentication.
|
||||
final class PersistentAuthenticationContext: PersistedAuthenticationContext {
|
||||
|
||||
/// The Secret to persist authentication for.
|
||||
let secret: Secret
|
||||
/// The LAContext used to authorize the persistent context.
|
||||
nonisolated(unsafe) let context: LAContext
|
||||
/// An expiration date for the context.
|
||||
/// - Note - Monotonic time instead of Date() to prevent people setting the clock back.
|
||||
let monotonicExpiration: UInt64
|
||||
|
||||
/// Initializes a context.
|
||||
/// - Parameters:
|
||||
/// - secret: The Secret to persist authentication for.
|
||||
/// - context: The LAContext used to authorize the persistent context.
|
||||
/// - duration: The duration of the authorization context, in seconds.
|
||||
init(secret: Secret, context: LAContext, duration: TimeInterval) {
|
||||
self.secret = secret
|
||||
unsafe self.context = context
|
||||
let durationInNanoSeconds = Measurement(value: duration, unit: UnitDuration.seconds).converted(to: .nanoseconds).value
|
||||
self.monotonicExpiration = clock_gettime_nsec_np(CLOCK_MONOTONIC) + UInt64(durationInNanoSeconds)
|
||||
}
|
||||
|
||||
/// A boolean describing whether or not the context is still valid.
|
||||
var valid: Bool {
|
||||
clock_gettime_nsec_np(CLOCK_MONOTONIC) < monotonicExpiration
|
||||
}
|
||||
|
||||
var expiration: Date {
|
||||
let remainingNanoseconds = monotonicExpiration - clock_gettime_nsec_np(CLOCK_MONOTONIC)
|
||||
let remainingInSeconds = Measurement(value: Double(remainingNanoseconds), unit: UnitDuration.nanoseconds).converted(to: .seconds).value
|
||||
return Date(timeIntervalSinceNow: remainingInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
actor PersistentAuthenticationHandler: Sendable {
|
||||
|
||||
private var persistedAuthenticationContexts: [Secret: PersistentAuthenticationContext] = [:]
|
||||
|
||||
func existingPersistedAuthenticationContext(secret: Secret) -> PersistentAuthenticationContext? {
|
||||
guard let persisted = persistedAuthenticationContexts[secret], persisted.valid else { return nil }
|
||||
return persisted
|
||||
}
|
||||
|
||||
func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
let newContext = LAContext()
|
||||
newContext.touchIDAuthenticationAllowableReuseDuration = duration
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .spellOut
|
||||
formatter.allowedUnits = [.hour, .minute, .day]
|
||||
|
||||
|
||||
let durationString = formatter.string(from: duration)!
|
||||
newContext.localizedReason = String(localized: .authContextPersistForDuration(secretName: secret.name, duration: durationString))
|
||||
let success = try await newContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: newContext.localizedReason)
|
||||
guard success else { return }
|
||||
let context = PersistentAuthenticationContext(secret: secret, context: newContext, duration: duration)
|
||||
persistedAuthenticationContexts[secret] = context
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,7 +17,7 @@ extension SecureEnclave {
|
||||
}
|
||||
public let id = UUID()
|
||||
public let name = String(localized: .secureEnclave)
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler()
|
||||
|
||||
/// Initializes a Store.
|
||||
@MainActor public init() {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
import CryptoKit
|
||||
import os
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
|
||||
public struct CertificateMigrator {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
|
||||
private let publicKeysDirectory: URL
|
||||
private let certificatesDirectory: URL
|
||||
private let certificateStore: CertificateStore
|
||||
|
||||
/// Initializes a PublicKeyFileStoreController.
|
||||
public init(homeDirectory: URL, certificateStore: CertificateStore) {
|
||||
publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
|
||||
certificatesDirectory = homeDirectory.appending(component: "Certificates")
|
||||
self.certificateStore = certificateStore
|
||||
}
|
||||
|
||||
@MainActor public func migrate() throws {
|
||||
try migrate(directory: publicKeysDirectory)
|
||||
try migrate(directory: certificatesDirectory)
|
||||
}
|
||||
|
||||
@MainActor public func migrate(directory: URL) throws {
|
||||
let fileCerts = try FileManager.default
|
||||
.contentsOfDirectory(atPath: directory.path())
|
||||
.filter { $0.hasSuffix("-cert.pub") }
|
||||
Task {
|
||||
for path in fileCerts {
|
||||
do {
|
||||
let url = directory.appending(component: path)
|
||||
let data = try Data(contentsOf: url)
|
||||
let parser = try await XPCCertificateParser()
|
||||
let cert = try await parser.parse(data: data)
|
||||
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Failed to delete successfully migrated cert: \(path)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to migrate cert: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -34,7 +34,6 @@ extension SmartCard {
|
||||
public var secrets: [Secret] {
|
||||
state.secrets
|
||||
}
|
||||
private let persistentAuthenticationHandler = PersistentAuthenticationHandler<Secret>()
|
||||
|
||||
/// Initializes a Store.
|
||||
public init() {
|
||||
@@ -59,15 +58,9 @@ extension SmartCard {
|
||||
|
||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||
guard let tokenID = await state.tokenID else { fatalError() }
|
||||
var context: LAContext
|
||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||
context = unsafe existing.context
|
||||
} else {
|
||||
let newContext = LAContext()
|
||||
newContext.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
context = newContext
|
||||
}
|
||||
let context = LAContext()
|
||||
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
||||
context.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
||||
let attributes = KeychainDictionary([
|
||||
kSecClass: kSecClassKey,
|
||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||
@@ -93,12 +86,11 @@ extension SmartCard {
|
||||
return signature as Data
|
||||
}
|
||||
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||
public func existingPersistedAuthenticationContext(secret: Secret) -> PersistedAuthenticationContext? {
|
||||
nil
|
||||
}
|
||||
|
||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||
public func persistAuthentication(secret: Secret, forDuration: TimeInterval) throws {
|
||||
}
|
||||
|
||||
/// Reloads all secrets from the store.
|
||||
@@ -171,7 +163,7 @@ extension SmartCard.Store {
|
||||
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .presenceRequired)
|
||||
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
|
||||
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
|
||||
guard signatureAlgorithm(for: secret) != nil else { return nil }
|
||||
return secret
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
private static let fallbackTeamID = "Z72PRUAWF6"
|
||||
|
||||
private static let teamID: String = {
|
||||
#if DEBUG
|
||||
guard let task = SecTaskCreateFromSelf(nil) else {
|
||||
assertionFailure("SecTaskCreateFromSelf failed")
|
||||
return fallbackTeamID
|
||||
}
|
||||
|
||||
guard let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.team-identifier" as CFString, nil) as? String else {
|
||||
assertionFailure("SecTaskCopyValueForEntitlement(com.apple.developer.team-identifier) failed")
|
||||
return fallbackTeamID
|
||||
}
|
||||
|
||||
return value
|
||||
#else
|
||||
/// Always use hardcoded team ID for release builds, just in case.
|
||||
return fallbackTeamID
|
||||
#endif
|
||||
}()
|
||||
|
||||
public var teamID: String { Self.teamID }
|
||||
}
|
||||
@@ -12,7 +12,7 @@ public final class XPCServiceDelegate: NSObject, NSXPCListenerDelegate {
|
||||
newConnection.exportedInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||
let exportedObject = exportedObject
|
||||
newConnection.exportedObject = exportedObject
|
||||
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
|
||||
newConnection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
||||
newConnection.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public struct XPCTypedSession<ResponseType: Codable & Sendable, ErrorType: Error
|
||||
public init(serviceName: String, warmup: Bool = false) async throws {
|
||||
let connection = NSXPCConnection(serviceName: serviceName)
|
||||
connection.remoteObjectInterface = NSXPCInterface(with: (any _XPCProtocol).self)
|
||||
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = \"\(ProcessInfo.processInfo.teamID)\"")
|
||||
connection.setCodeSigningRequirement("anchor apple generic and certificate leaf[subject.OU] = Z72PRUAWF6")
|
||||
connection.resume()
|
||||
guard let proxy = connection.remoteObjectProxy as? _XPCProtocol else { fatalError() }
|
||||
self.connection = connection
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import SecretKit
|
||||
import SSHProtocolKit
|
||||
@testable import SecureEnclaveSecretKit
|
||||
@testable import SmartCardSecretKit
|
||||
|
||||
@Suite struct OpenSSHPublicKeyWriterTests {
|
||||
|
||||
@@ -46,8 +47,8 @@ import SSHProtocolKit
|
||||
extension OpenSSHPublicKeyWriterTests {
|
||||
|
||||
enum Constants {
|
||||
static let ecdsa256Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||
static let ecdsa384Secret = TestSecret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SSHProtocolKit
|
||||
@testable import SecretKit
|
||||
|
||||
@Suite struct OpenSSHSignatureWriterTests {
|
||||
|
||||
private let writer = OpenSSHSignatureWriter()
|
||||
|
||||
@Test func ecdsaMpintStripsUnnecessaryLeadingZeros() throws {
|
||||
let secret = Constants.ecdsa256Secret
|
||||
|
||||
// r has a leading 0x00 followed by 0x01 (< 0x80): the mpint must not keep the leading zero.
|
||||
let rBytes: [UInt8] = [0x00] + (1...31).map { UInt8($0) }
|
||||
let r = Data(rBytes)
|
||||
// s has two leading 0x00 bytes followed by 0x7f (< 0x80): the mpint must not keep the leading zeros.
|
||||
let sBytes: [UInt8] = [0x00, 0x00, 0x7f] + Array(repeating: UInt8(0x01), count: 29)
|
||||
let s = Data(sBytes)
|
||||
let rawRepresentation = r + s
|
||||
|
||||
let response = writer.data(secret: secret, signature: rawRepresentation)
|
||||
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
||||
|
||||
#expect(parsedR == Data((1...31).map { UInt8($0) }))
|
||||
#expect(parsedS == Data([0x7f] + Array(repeating: UInt8(0x01), count: 29)))
|
||||
}
|
||||
|
||||
@Test func ecdsaMpintPrefixesZeroWhenHighBitSet() throws {
|
||||
let secret = Constants.ecdsa256Secret
|
||||
|
||||
// r starts with 0x80 (high bit set): mpint must be prefixed with 0x00.
|
||||
let r = Data([UInt8(0x80)] + Array(repeating: UInt8(0x01), count: 31))
|
||||
let s = Data([UInt8(0x01)] + Array(repeating: UInt8(0x02), count: 31))
|
||||
let rawRepresentation = r + s
|
||||
|
||||
let response = writer.data(secret: secret, signature: rawRepresentation)
|
||||
let (parsedR, parsedS) = try parseEcdsaSignatureMpints(from: response)
|
||||
|
||||
#expect(parsedR == Data([0x00, 0x80] + Array(repeating: UInt8(0x01), count: 31)))
|
||||
#expect(parsedS == Data([0x01] + Array(repeating: UInt8(0x02), count: 31)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension OpenSSHSignatureWriterTests {
|
||||
|
||||
enum Constants {
|
||||
static let ecdsa256Secret = TestSecret(
|
||||
id: Data(),
|
||||
name: "Test Key (ECDSA 256)",
|
||||
publicKey: Data(repeating: 0x01, count: 65),
|
||||
attributes: Attributes(
|
||||
keyType: KeyType(algorithm: .ecdsa, size: 256),
|
||||
authentication: .notRequired,
|
||||
publicKeyAttribution: "test@example.com"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
enum ParseError: Error {
|
||||
case eof
|
||||
case invalidAlgorithm
|
||||
}
|
||||
|
||||
func parseEcdsaSignatureMpints(from openSSHSignedData: Data) throws -> (r: Data, s: Data) {
|
||||
let reader = OpenSSHReader(data: openSSHSignedData)
|
||||
|
||||
// Prefix
|
||||
_ = try reader.readNextBytes(as: UInt32.self)
|
||||
|
||||
let algorithm = try reader.readNextChunkAsString()
|
||||
guard algorithm == "ecdsa-sha2-nistp256" else {
|
||||
throw ParseError.invalidAlgorithm
|
||||
}
|
||||
|
||||
let sigReader = try reader.readNextChunkAsSubReader()
|
||||
let r = try sigReader.readNextChunk()
|
||||
let s = try sigReader.readNextChunk()
|
||||
return (r, s)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Foundation
|
||||
import SecretKit
|
||||
|
||||
public struct TestSecret: SecretKit.Secret {
|
||||
|
||||
public let id: Data
|
||||
public let name: String
|
||||
public let publicKey: Data
|
||||
public var attributes: Attributes
|
||||
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import CryptoKit
|
||||
import CertificateKit
|
||||
@testable import SSHProtocolKit
|
||||
@testable import SecretKit
|
||||
@testable import SecretAgentKit
|
||||
|
||||
@Suite @MainActor struct AgentTests {
|
||||
@Suite struct AgentTests {
|
||||
|
||||
// MARK: Identity Listing
|
||||
|
||||
@Test func emptyStores() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: SecretStoreList())
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||
let response = await agent.handle(request: request, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestIdentitiesEmpty)
|
||||
@@ -19,7 +17,7 @@ import CertificateKit
|
||||
|
||||
@Test func identitiesList() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities)
|
||||
let response = await agent.handle(request: request, provenance: .test)
|
||||
|
||||
@@ -33,7 +31,7 @@ import CertificateKit
|
||||
|
||||
@Test func noMatchingIdentities() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching)
|
||||
let response = await agent.handle(request: request, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
@@ -43,11 +41,11 @@ import CertificateKit
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
guard case SSHAgent.Request.signRequest(let context) = request else { return }
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list)
|
||||
let response = await agent.handle(request: request, provenance: .test)
|
||||
let responseReader = OpenSSHReader(data: response)
|
||||
let length = try responseReader.readNextBytes(as: UInt32.self)
|
||||
let type = try responseReader.readNextBytes(as: UInt8.self)
|
||||
let length = try responseReader.readNextBytes(as: UInt32.self).bigEndian
|
||||
let type = try responseReader.readNextBytes(as: UInt8.self).bigEndian
|
||||
#expect(length == response.count - MemoryLayout<UInt32>.size)
|
||||
#expect(type == SSHAgent.Response.agentSignResponse.rawValue)
|
||||
let outer = OpenSSHReader(data: responseReader.remaining)
|
||||
@@ -78,7 +76,7 @@ import CertificateKit
|
||||
let witness = StubWitness(speakNow: { _,_ in
|
||||
return true
|
||||
}, witness: { _, _ in })
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
let response = await agent.handle(request: .signRequest(.empty), provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
@@ -91,7 +89,7 @@ import CertificateKit
|
||||
}, witness: { _, trace in
|
||||
witnessed = true
|
||||
})
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
_ = await agent.handle(request: request, provenance: .test)
|
||||
#expect(witnessed)
|
||||
@@ -107,7 +105,7 @@ import CertificateKit
|
||||
}, witness: { _, trace in
|
||||
witnessTrace = trace
|
||||
})
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness)
|
||||
let agent = Agent(storeList: list, witness: witness)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
_ = await agent.handle(request: request, provenance: .test)
|
||||
#expect(witnessTrace == speakNowTrace)
|
||||
@@ -118,9 +116,9 @@ import CertificateKit
|
||||
|
||||
@Test func signatureException() async throws {
|
||||
let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret])
|
||||
let store = list.stores.first?.base as! Stub.Store
|
||||
let store = await list.stores.first?.base as! Stub.Store
|
||||
store.shouldThrow = true
|
||||
let agent = Agent(storeList: list, certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: list)
|
||||
let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature)
|
||||
let response = await agent.handle(request: request, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
@@ -129,7 +127,7 @@ import CertificateKit
|
||||
// MARK: Unsupported
|
||||
|
||||
@Test func unhandledAdd() async throws {
|
||||
let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore())
|
||||
let agent = Agent(storeList: SecretStoreList())
|
||||
let response = await agent.handle(request: .addIdentity, provenance: .test)
|
||||
#expect(response == Constants.Responses.requestFailure)
|
||||
}
|
||||
@@ -144,7 +142,7 @@ extension SigningRequestProvenance {
|
||||
|
||||
extension AgentTests {
|
||||
|
||||
func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
@MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList {
|
||||
let store = Stub.Store()
|
||||
store.secrets.append(contentsOf: secrets)
|
||||
let storeList = SecretStoreList()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SSHProtocolKit
|
||||
@testable import SecretAgentKit
|
||||
@testable import SecureEnclaveSecretKit
|
||||
@testable import SmartCardSecretKit
|
||||
|
||||
@Suite struct OpenSSHReaderTests {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import SecretKit
|
||||
import CryptoKit
|
||||
import SSHProtocolKit
|
||||
|
||||
struct Stub {}
|
||||
|
||||
|
||||
@@ -9,18 +9,7 @@ import Observation
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
import Common
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
|
||||
|
||||
@MainActor var certificateStore: CertificateStore {
|
||||
EnvironmentValues._certificateStore
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@main
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@@ -29,17 +18,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let cryptoKit = SecureEnclave.Store()
|
||||
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||
try? migrator.migrate(to: cryptoKit)
|
||||
let certsMigrator = CertificateKitMigrator(homeDirectory: URL.homeDirectory)
|
||||
try? certsMigrator.migrate()
|
||||
list.add(store: cryptoKit)
|
||||
list.add(store: SmartCard.Store())
|
||||
let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: EnvironmentValues._certificateStore)
|
||||
try? certsMigrator.migrate()
|
||||
return list
|
||||
}()
|
||||
private let updater = Updater(checkOnLaunch: true)
|
||||
private let notifier = Notifier()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory)
|
||||
@MainActor private lazy var agent: Agent = {
|
||||
Agent(storeList: storeList, certificateStore: EnvironmentValues._certificateStore, witness: notifier)
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.homeDirectory)
|
||||
private lazy var agent: Agent = {
|
||||
Agent(storeList: storeList, witness: notifier)
|
||||
}()
|
||||
private lazy var socketController: SocketController = {
|
||||
let path = URL.socketPath as String
|
||||
@@ -50,9 +39,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
logger.debug("SecretAgent finished launching")
|
||||
Task {
|
||||
let inputParser = try await XPCAgentInputParser()
|
||||
for await session in socketController.sessions {
|
||||
Task {
|
||||
let inputParser = try await XPCAgentInputParser()
|
||||
do {
|
||||
for await message in session.messages {
|
||||
let request = try await inputParser.parse(data: message)
|
||||
@@ -70,13 +59,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
}
|
||||
}
|
||||
Task {
|
||||
for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) {
|
||||
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
|
||||
}
|
||||
}
|
||||
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true)
|
||||
try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true)
|
||||
notifier.prompt()
|
||||
_ = withObservationTracking {
|
||||
updater.update
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoTokenKit
|
||||
import CryptoKit
|
||||
import os
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
import SharedXPCServices
|
||||
|
||||
public struct CertificateMigrator {
|
||||
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
|
||||
private let directory: URL
|
||||
private let certificateStore: CertificateStore
|
||||
|
||||
/// Initializes a PublicKeyFileStoreController.
|
||||
public init(homeDirectory: URL, certificateStore: CertificateStore) {
|
||||
directory = homeDirectory.appending(component: "PublicKeys")
|
||||
self.certificateStore = certificateStore
|
||||
}
|
||||
|
||||
@MainActor public func migrate() throws {
|
||||
let fileCerts = try FileManager.default
|
||||
.contentsOfDirectory(atPath: directory.path())
|
||||
.filter { $0.hasSuffix("-cert.pub") }
|
||||
Task {
|
||||
for path in fileCerts {
|
||||
do {
|
||||
let url = directory.appending(component: path)
|
||||
let data = try Data(contentsOf: url)
|
||||
let parser = try await XPCCertificateParser()
|
||||
let cert = try await parser.parse(data: data)
|
||||
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
logger.error("Failed to delete successfully migrated cert: \(path)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to migrate cert: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,22 +4,16 @@
|
||||
<dict>
|
||||
<key>com.apple.security.hardened-process</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
||||
<string>1</string>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
||||
<integer>1</integer>
|
||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
||||
<integer>2</integer>
|
||||
<key>com.apple.security.smartcard</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||
<string>2</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>
|
||||
|
||||
@@ -3,8 +3,6 @@ import OSLog
|
||||
import SSHProtocolKit
|
||||
import Brief
|
||||
import XPCWrappers
|
||||
import OSLog
|
||||
import SSHProtocolKit
|
||||
|
||||
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
||||
public final class XPCAgentInputParser: SSHAgentInputParserProtocol {
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
||||
<integer>1</integer>
|
||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
||||
<string>1</string>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||
<string>2</string>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import XPCWrappers
|
||||
import SecretAgentKit
|
||||
import SSHProtocolKit
|
||||
|
||||
final class SecretAgentInputParser: NSObject, XPCProtocol {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
@@ -14,8 +14,7 @@
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:Config/Secretive.xctestplan"
|
||||
default = "YES">
|
||||
reference = "container:Config/Secretive.xctestplan">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
</TestAction>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2640"
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
import Brief
|
||||
import XPCWrappers
|
||||
|
||||
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH
|
||||
@@ -4,22 +4,16 @@
|
||||
<dict>
|
||||
<key>com.apple.security.hardened-process</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations.enable-pure-data</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.checked-allocations.no-tagged-receive</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
||||
<integer>1</integer>
|
||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
||||
<string>1</string>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
||||
<integer>2</integer>
|
||||
<key>com.apple.security.smartcard</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||
<string>2</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.maxgoedjen.Secretive</string>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
import Common
|
||||
|
||||
struct ToolConfigurationView: View {
|
||||
|
||||
@@ -113,9 +112,10 @@ struct ToolConfigurationView: View {
|
||||
let writer = OpenSSHPublicKeyWriter()
|
||||
let gitAllowedSignersString = [email.isEmpty ? String(localized: .integrationsConfigureUsingEmailPlaceholder) : email, writer.openSSHString(secret: selectedSecret)]
|
||||
.joined(separator: " ")
|
||||
let fileController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
return text
|
||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPlaceholder, with: gitAllowedSignersString)
|
||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: URL.publicKeyPath(for: selectedSecret, in: URL.publicKeyDirectory))
|
||||
.replacingOccurrences(of: Instructions.Constants.publicKeyPathPlaceholder, with: fileController.publicKeyPath(for: selectedSecret))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import Common
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
import CryptoKit
|
||||
struct CertificateDetailView: View {
|
||||
|
||||
let certificate: Certificate
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(
|
||||
title: .certificateDetailKeyIdLabel,
|
||||
image: Image(systemName: "person.text.rectangle"),
|
||||
text: certificate.keyID
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .certificateDetailSerialLabel,
|
||||
image: Image(systemName: "number.circle"),
|
||||
text: certificate.serial.formatted()
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .secretDetailSha256FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.publicKey)
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .secretDetailSha256FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.signingKey)
|
||||
)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .certificateDetailPathLabel,
|
||||
image: Image(systemName: "checkmark.seal.text.page"),
|
||||
text: URL.certificatePath(for: certificate.id, in: URL.certificatesDirectory),
|
||||
showRevealInFinder: true
|
||||
)
|
||||
if let validityRange = certificate.validityRange {
|
||||
let epoch = Date(timeIntervalSince1970: 0)
|
||||
let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max))
|
||||
switch (validityRange.lowerBound, validityRange.upperBound) {
|
||||
case (epoch, end):
|
||||
EmptyView()
|
||||
case (epoch, let otherEnd):
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()])
|
||||
case (let otherStart, end):
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()])
|
||||
default:
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
|
||||
}
|
||||
}
|
||||
if !certificate.principals.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
|
||||
}
|
||||
if !certificate.criticalOptions.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailCriticalOptionsLabel, image: Image(systemName: "person.2"), items: certificate.criticalOptions)
|
||||
}
|
||||
if !certificate.extensions.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(title: .certificateDetailExtensionsLabel, image: Image(systemName: "person.2"), items: certificate.extensions)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(minHeight: 200, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import SwiftUI
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
|
||||
struct CertificateListItemView: View {
|
||||
|
||||
@Environment(\.certificateStore) private var store
|
||||
|
||||
var certificate: Certificate
|
||||
|
||||
@State var isDeleting: Bool = false
|
||||
@State var isRenaming: Bool = false
|
||||
|
||||
var deletedCertificate: (Certificate) -> Void
|
||||
var renamedCertificate: (Certificate) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: certificate) {
|
||||
Text(certificate.name)
|
||||
}
|
||||
.sheet(isPresented: $isRenaming, onDismiss: {
|
||||
renamedCertificate(certificate)
|
||||
}, content: {
|
||||
EditCertificateView(store: store, certificate: certificate)
|
||||
})
|
||||
.showingDeleteConfirmation(isPresented: $isDeleting, certificate, store) { deleted in
|
||||
if deleted {
|
||||
deletedCertificate(certificate)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: { isRenaming = true }) {
|
||||
Image(systemName: "pencil")
|
||||
Text(.secretListEditButton)
|
||||
}
|
||||
Button(action: { isDeleting = true }) {
|
||||
Image(systemName: "trash")
|
||||
Text(.secretListDeleteButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import SwiftUI
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
|
||||
extension View {
|
||||
|
||||
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: Certificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View {
|
||||
modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DeleteCertificateConfirmationModifier: ViewModifier {
|
||||
|
||||
var isPresented: Binding<Bool>
|
||||
var certificate: Certificate
|
||||
var store: CertificateStore
|
||||
var dismissalBlock: (Bool) -> ()
|
||||
@State var confirmedSecretName = ""
|
||||
@State private var errorText: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
String(localized: .deleteConfirmationTitle(name: certificate.name)),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
Button(.deleteConfirmationDeleteButton, action: delete)
|
||||
Button(.deleteConfirmationCancelButton, role: .cancel) {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill"))
|
||||
.onExitCommand {
|
||||
dismissalBlock(false)
|
||||
}
|
||||
}
|
||||
|
||||
func delete() {
|
||||
Task {
|
||||
do {
|
||||
try store.delete(certificate: certificate)
|
||||
dismissalBlock(true)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.confirmationDialog(
|
||||
.deleteConfirmationTitle(name: secret.name),
|
||||
.deleteConfirmationTitle(secretName: secret.name),
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible,
|
||||
actions: {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import SwiftUI
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
|
||||
struct EditCertificateView: View {
|
||||
|
||||
let store: CertificateStore
|
||||
let certificate: Certificate
|
||||
|
||||
@State private var name: String
|
||||
@State var errorText: String?
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(store: CertificateStore, certificate: Certificate) {
|
||||
self.store = store
|
||||
self.certificate = certificate
|
||||
name = certificate.name
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Form {
|
||||
Section {
|
||||
TextField(String(localized: .renameCertificateLabel), text: $name, prompt: Text(.renameCertificateNamePlaceholder))
|
||||
} footer: {
|
||||
if let errorText {
|
||||
Text(verbatim: errorText)
|
||||
.errorStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button(.editCancelButton) {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(.editSaveButton, action: rename)
|
||||
.disabled(name.isEmpty)
|
||||
.keyboardShortcut(.return)
|
||||
.primaryButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
func rename() {
|
||||
Task {
|
||||
do {
|
||||
var updated = certificate
|
||||
updated.openSSHCertificate.name = name
|
||||
try store.update(certificate: updated)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,28 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import Common
|
||||
import CertificateKit
|
||||
import SSHProtocolKit
|
||||
|
||||
struct SecretDetailView<SecretType: Secret>: View {
|
||||
|
||||
let secret: SecretType
|
||||
let certificates: [Certificate]
|
||||
let navigateToCertificate: ((Certificate) -> Void)?
|
||||
|
||||
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: URL.agentHomeURL)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Form {
|
||||
Section {
|
||||
CopyableView(
|
||||
title: .secretDetailSha256FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: keyWriter.openSSHSHA256Fingerprint(secret: secret)
|
||||
)
|
||||
CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .secretDetailMd5FingerprintLabel,
|
||||
image: Image(systemName: "touchid"),
|
||||
text: keyWriter.openSSHMD5Fingerprint(secret: secret)
|
||||
)
|
||||
CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret))
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(
|
||||
title: .secretDetailPublicKeyPathLabel,
|
||||
image: Image(systemName: "lock.doc"),
|
||||
text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory),
|
||||
showRevealInFinder: true
|
||||
)
|
||||
if !certificates.isEmpty {
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
MultilineInfoView(
|
||||
title: .secretDetailCertificatePathLabel,
|
||||
image: Image(
|
||||
systemName: "checkmark.seal.text.page"
|
||||
),
|
||||
items: certificates.map({ certificate in
|
||||
MultilineInfoView.Item(
|
||||
text: certificate.name,
|
||||
action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) })
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString)
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret), showRevealInFinder: true)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -61,6 +32,10 @@ struct SecretDetailView<SecretType: Secret>: View {
|
||||
}
|
||||
|
||||
|
||||
var keyString: String {
|
||||
keyWriter.openSSHString(secret: secret)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import SwiftUI
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
|
||||
struct StoreListView: View {
|
||||
|
||||
enum StoreListSelection: Hashable {
|
||||
case secret(AnySecret)
|
||||
case certificate(Certificate)
|
||||
}
|
||||
|
||||
@Binding var selection: StoreListSelection?
|
||||
@Binding var activeSecret: AnySecret?
|
||||
|
||||
@Environment(\.secretStoreList) private var storeList
|
||||
@Environment(\.certificateStore) private var certificateStore
|
||||
|
||||
private func secretDeleted(secret: AnySecret) {
|
||||
selection = nextDefaultSecret.map(StoreListSelection.secret)
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
|
||||
private func secretRenamed(secret: AnySecret) {
|
||||
// Pull new version from store, so we get all updated attributes
|
||||
selection = nil
|
||||
selection = storeList.allSecrets.first(where: { $0.id == secret.id }).map(StoreListSelection.secret)
|
||||
activeSecret = nil
|
||||
activeSecret = storeList.allSecrets.first(where: { $0.id == secret.id })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
List(selection: $activeSecret) {
|
||||
ForEach(storeList.stores) { store in
|
||||
if store.isAvailable {
|
||||
Section(header: Text(store.name)) {
|
||||
@@ -38,51 +30,29 @@ struct StoreListView: View {
|
||||
deletedSecret: secretDeleted,
|
||||
renamedSecret: secretRenamed,
|
||||
)
|
||||
.tag(StoreListSelection.secret(secret))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !certificateStore.certificates.isEmpty {
|
||||
Section("Certificates") {
|
||||
ForEach(certificateStore.certificates) { certificate in
|
||||
CertificateListItemView(
|
||||
certificate: certificate,
|
||||
deletedCertificate: { _ in },
|
||||
renamedCertificate: { _ in }
|
||||
)
|
||||
.tag(StoreListSelection.certificate(certificate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
switch selection {
|
||||
case .secret(let secret):
|
||||
SecretDetailView(secret: secret, certificates: certificateStore.certificates(for: secret)) {
|
||||
selection = .certificate($0)
|
||||
}
|
||||
case .certificate(let certificate):
|
||||
CertificateDetailView(certificate: certificate)
|
||||
case nil:
|
||||
if let nextDefaultSecret {
|
||||
// This just means onAppear hasn't executed yet.
|
||||
// Do this to avoid a blip.
|
||||
SecretDetailView(secret: nextDefaultSecret, certificates: certificateStore.certificates(for: nextDefaultSecret)) {
|
||||
selection = .certificate($0)
|
||||
}
|
||||
if let activeSecret {
|
||||
SecretDetailView(secret: activeSecret)
|
||||
} else if let nextDefaultSecret {
|
||||
// This just means onAppear hasn't executed yet.
|
||||
// Do this to avoid a blip.
|
||||
SecretDetailView(secret: nextDefaultSecret)
|
||||
} else {
|
||||
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
|
||||
EmptyStoreView(store: modifiable)
|
||||
} else {
|
||||
if let modifiable = storeList.modifiableStore, modifiable.isAvailable {
|
||||
EmptyStoreView(store: modifiable)
|
||||
} else {
|
||||
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
|
||||
}
|
||||
EmptyStoreView(store: storeList.stores.first(where: \.isAvailable))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onAppear {
|
||||
selection = nextDefaultSecret.map(StoreListSelection.secret)
|
||||
activeSecret = nextDefaultSecret
|
||||
}
|
||||
.frame(minWidth: 100, idealWidth: 240)
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ import SecureEnclaveSecretKit
|
||||
import SmartCardSecretKit
|
||||
import Brief
|
||||
import SSHProtocolKit
|
||||
import SharedXPCServices
|
||||
import CertificateKit
|
||||
|
||||
struct ContentView: View {
|
||||
|
||||
@State var selection: StoreListView.StoreListSelection?
|
||||
@State var activeSecret: AnySecret?
|
||||
|
||||
@State private var selectedUpdate: Release?
|
||||
|
||||
@@ -29,7 +27,7 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
if storeList.anyAvailable {
|
||||
StoreListView(selection: $selection)
|
||||
StoreListView(activeSecret: $activeSecret)
|
||||
} else {
|
||||
NoStoresView()
|
||||
}
|
||||
@@ -49,16 +47,16 @@ struct ContentView: View {
|
||||
.dropDestination(for: URL.self) { items, location in
|
||||
guard let url = items.first, url.pathExtension == "pub" else { return false }
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let parser = try await XPCCertificateParser()
|
||||
let cert = try await parser.parse(data: data)
|
||||
let wrapped = Certificate(openSSHCertificate: cert, rawData: data)
|
||||
try certificateStore.save(certificate: wrapped)
|
||||
selection = .certificate(wrapped)
|
||||
} catch {
|
||||
|
||||
let data = try! Data(contentsOf: url)
|
||||
let parser = try! await XPCCertificateParser()
|
||||
let cert = try! await parser.parse(data: data)
|
||||
let secret = storeList.allSecrets.first { secret in
|
||||
secret.name == cert.name
|
||||
}
|
||||
guard let secret = secret ?? storeList.allSecrets.first else { return }
|
||||
print(cert.data.formatted(.hex()))
|
||||
certificateStore.saveCertificate(cert.data, for: secret)
|
||||
print(cert)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { _ in }
|
||||
@@ -69,7 +67,7 @@ struct ContentView: View {
|
||||
if let modifiable = storeList.modifiableStore {
|
||||
CreateSecretView(store: modifiable) { created in
|
||||
if let created {
|
||||
selection = .secret(created)
|
||||
activeSecret = created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,12 @@ import UniformTypeIdentifiers
|
||||
struct CopyableView: View {
|
||||
|
||||
var title: LocalizedStringResource
|
||||
var subtitle: String?
|
||||
var image: Image
|
||||
var text: String
|
||||
var showRevealInFinder = false
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
|
||||
|
||||
var content: some View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
HStack {
|
||||
@@ -18,16 +17,9 @@ struct CopyableView: View {
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
.foregroundColor(primaryTextColor)
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
}
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Spacer()
|
||||
if interactionState != .normal {
|
||||
HStack {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct MultilineInfoView: View {
|
||||
|
||||
struct Item {
|
||||
let text: String
|
||||
let action: (Image, () -> Void)?
|
||||
}
|
||||
|
||||
var title: LocalizedStringResource
|
||||
var image: Image
|
||||
var items: [Item]
|
||||
|
||||
init(title: LocalizedStringResource, image: Image, items: [Item]) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(title: LocalizedStringResource, image: Image, items: [String]) {
|
||||
self.title = title
|
||||
self.image = image
|
||||
self.items = items.map({ Item(text: $0, action: nil) })
|
||||
}
|
||||
|
||||
@State private var interactionState: InteractionState = .normal
|
||||
@State private var interactionStateIndex: Int?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
image
|
||||
.renderingMode(.template)
|
||||
.imageScale(.large)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundColor(primaryTextColor)
|
||||
Spacer()
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { item in
|
||||
Divider()
|
||||
.ignoresSafeArea()
|
||||
.opacity(item.offset == 0 ? 1 : 0.75)
|
||||
HStack {
|
||||
Text(item.element.text)
|
||||
Spacer()
|
||||
if let (image, _) = item.element.action {
|
||||
image
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(20)
|
||||
._background(interactionState: interactionStateIndex == item.offset ? interactionState : .normal, cornerRadius: 0)
|
||||
.onHover { hovering in
|
||||
withAnimation {
|
||||
guard item.element.action != nil else { return }
|
||||
interactionState = hovering ? .hovering : .normal
|
||||
interactionStateIndex = item.offset
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
TapGesture()
|
||||
.onEnded {
|
||||
item.element.action?.1()
|
||||
withAnimation {
|
||||
interactionState = .normal
|
||||
interactionStateIndex = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
._background(interactionState: .normal)
|
||||
.frame(minWidth: 150, maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var primaryTextColor: Color {
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.textColor)
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryTextColor: Color {
|
||||
switch interactionState {
|
||||
case .normal, .hovering:
|
||||
return Color(.secondaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate enum InteractionState {
|
||||
case normal, hovering
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View {
|
||||
modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate struct BackgroundViewModifier: ViewModifier {
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.appearsActive) private var appearsActive
|
||||
|
||||
let interactionState: InteractionState
|
||||
let cornerRadius: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
content
|
||||
// Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow.
|
||||
.background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15))
|
||||
.glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.shadow(color: .black.opacity(0.1), radius: 5)
|
||||
} else {
|
||||
content
|
||||
.background(backgroundColor(interactionState: interactionState))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundColor(interactionState: InteractionState) -> Color {
|
||||
guard appearsActive else { return Color.clear }
|
||||
if #available(macOS 26.0, *) {
|
||||
let base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1)
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return base
|
||||
case .hovering:
|
||||
return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1)
|
||||
}
|
||||
} else {
|
||||
switch interactionState {
|
||||
case .normal:
|
||||
return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885)
|
||||
case .hovering:
|
||||
return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MultilineInfoView(title: "Multiple", image: Image(systemName: "figure.wave"), items: [
|
||||
MultilineInfoView.Item(text: "hello", action: (Image(systemName: "chevron.forward"), {})),
|
||||
MultilineInfoView.Item(text: "World", action: (Image(systemName: "chevron.forward"), {})),
|
||||
])
|
||||
.padding()
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world."])
|
||||
.padding()
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
import OSLog
|
||||
import XPCWrappers
|
||||
import SSHProtocolKit
|
||||
import CertificateKit
|
||||
|
||||
final class SecretiveCertificateParser: NSObject, XPCProtocol {
|
||||
|
||||
@@ -11,7 +10,7 @@ final class SecretiveCertificateParser: NSObject, XPCProtocol {
|
||||
func process(_ data: Data) async throws -> OpenSSHCertificate {
|
||||
let parser = OpenSSHCertificateParser()
|
||||
let result = try parser.parse(data: data)
|
||||
logger.log("Parser parsed certificate")
|
||||
logger.log("Parser parsed certificate \(result.debugDescription)")
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.dyld-ro</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
|
||||
<string>1</string>
|
||||
<key>com.apple.security.hardened-process.enhanced-security-version</key>
|
||||
<integer>1</integer>
|
||||
<key>com.apple.security.hardened-process.hardened-heap</key>
|
||||
<true/>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions-string</key>
|
||||
<string>2</string>
|
||||
<key>com.apple.security.hardened-process.platform-restrictions</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
TEAM_ID_FILE=Sources/Config/OpenSource.xcconfig
|
||||
|
||||
function print_team_ids() {
|
||||
echo ""
|
||||
echo "FYI, here are the team IDs found in your Xcode preferences:"
|
||||
echo ""
|
||||
|
||||
XCODEPREFS="$HOME/Library/Preferences/com.apple.dt.Xcode.plist"
|
||||
TEAM_KEYS=(`/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams" "$XCODEPREFS" | perl -lne 'print $1 if /^ (\S*) =/'`)
|
||||
|
||||
for KEY in $TEAM_KEYS
|
||||
do
|
||||
i=0
|
||||
while true ; do
|
||||
NAME=$(/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams:$KEY:$i:teamName" "$XCODEPREFS" 2>/dev/null)
|
||||
TEAMID=$(/usr/libexec/PlistBuddy -c "Print :IDEProvisioningTeams:$KEY:$i:teamID" "$XCODEPREFS" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
echo "$TEAMID - $NAME"
|
||||
|
||||
i=$(($i + 1))
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
print_team_ids
|
||||
echo ""
|
||||
echo "> What is your Apple Developer Team ID? (looks like 1A23BDCD)"
|
||||
read TEAM_ID
|
||||
else
|
||||
TEAM_ID=$1
|
||||
fi
|
||||
|
||||
if [ -z "$TEAM_ID" ]; then
|
||||
echo "You must enter a team id"
|
||||
print_team_ids
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Setting team ID to $TEAM_ID"
|
||||
|
||||
echo "// This file was automatically generated, do not edit directly." > $TEAM_ID_FILE
|
||||
echo "" >> $TEAM_ID_FILE
|
||||
echo "SECRETIVE_BASE_BUNDLE_ID_OSS=${TEAM_ID}.com.example.Secretive" >> $TEAM_ID_FILE
|
||||
echo "SECRETIVE_DEVELOPMENT_TEAM_OSS=${TEAM_ID}" >> $TEAM_ID_FILE
|
||||
|
||||
echo ""
|
||||
echo "Successfully generated configuration at $TEAM_ID_FILE, you may now build the app using the \"Secretive\" target"
|
||||
echo "You may need to close and re-open the project in Xcode if it's already open"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user