mirror of
https://github.com/maxgoedjen/secretive.git
synced 2025-08-26 23:20:57 +00:00
CryptoKit migration (#628)
* WIP. * WIP * WIP Edit * Key selection. * WIP * WIP * Proxy through * WIP * Remove verify. * Migration. * Comment * Add param * Semi-offering key * Ignore updates if test build. * Fix rsa public key gen * Messily fix RSA * Remove 1024 bit rsa * Cleanup * Cleanup * Clean out MLDSA refs for now * Dump notifier changes * Put back UI tweaks * Fixes.
This commit is contained in:
parent
5b0135d694
commit
3d3d123484
@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"Advanced" : {
|
||||||
|
|
||||||
|
},
|
||||||
"agent_not_running_notice_title" : {
|
"agent_not_running_notice_title" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1083,6 +1086,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"auth_context_request_signature_description_%@_%@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "auth_context_request_signature_description_%1$@_%2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"auth_context_request_verify_description" : {
|
"auth_context_request_verify_description" : {
|
||||||
"comment" : "When the user performs a signature verification action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.",
|
"comment" : "When the user performs a signature verification action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@ -1475,72 +1488,72 @@
|
|||||||
"ca" : {
|
"ca" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nom:"
|
"value" : "Nom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Name:"
|
"value" : "Name"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Name:"
|
"value" : "Name"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fi" : {
|
"fi" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nimi:"
|
"value" : "Nimi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nom :"
|
"value" : "Nom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nome:"
|
"value" : "Nome"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "名前:"
|
"value" : "名前"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ko" : {
|
"ko" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "이름:"
|
"value" : "이름"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pl" : {
|
"pl" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nazwa:"
|
"value" : "Nazwa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nome:"
|
"value" : "Nome"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru" : {
|
"ru" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Название:"
|
"value" : "Название"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "名称"
|
"value" : "名称"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2007,6 +2020,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Current Biometrics" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"delete_confirmation_cancel_button" : {
|
"delete_confirmation_cancel_button" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@ -2091,72 +2107,72 @@
|
|||||||
"ca" : {
|
"ca" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Confirma el nom:"
|
"value" : "Confirma el nom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Name bestätigen:"
|
"value" : "Name bestätigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Confirm Name:"
|
"value" : "Confirm Name"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fi" : {
|
"fi" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Vahvista nimi:"
|
"value" : "Vahvista nimi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Confirmer le nom :"
|
"value" : "Confirmer le nom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Conferma nome:"
|
"value" : "Conferma nome"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "名前の確認:"
|
"value" : "名前の確認"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ko" : {
|
"ko" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "확인 이름:"
|
"value" : "확인 이름"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pl" : {
|
"pl" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Powtórz nazwę:"
|
"value" : "Powtórz nazwę"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Confirmar Nome:"
|
"value" : "Confirmar Nome"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru" : {
|
"ru" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Подтвердить название:"
|
"value" : "Подтвердить название"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "确认名称"
|
"value" : "确认名称"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2777,6 +2793,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"If you change your biometric settings in _any way_, including adding a new fingerprint, this key will no longer be accessible." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Key Attribution" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Key Type" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"no_secure_storage_description" : {
|
"no_secure_storage_description" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@ -3377,6 +3402,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Require authentication with current set of biometrics." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"secret_detail_md5_fingerprint_label" : {
|
"secret_detail_md5_fingerprint_label" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@ -3733,72 +3761,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"secret_list_rename_button" : {
|
"secret_list_edit_button" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ca" : {
|
"ca" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Canvia el nom"
|
"value" : "Canvia el nom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Umbenennen"
|
"value" : "Umbenennen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Rename"
|
"value" : "Edit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Renommer"
|
"value" : "Renommer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Rinomina"
|
"value" : "Rinomina"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "名前を変更"
|
"value" : "名前を変更"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ko" : {
|
"ko" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "이름 변경"
|
"value" : "이름 변경"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pl" : {
|
"pl" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Zmień nazwę"
|
"value" : "Zmień nazwę"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Renomear"
|
"value" : "Renomear"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru" : {
|
"ru" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "Переименовать"
|
"value" : "Переименовать"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "needs_review",
|
||||||
"value" : "重命名"
|
"value" : "重命名"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5172,6 +5200,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"test@example.com" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"This shows at the end of your public key." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"unnamed_secret" : {
|
"unnamed_secret" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
|
@ -9,7 +9,8 @@ public final class Agent: Sendable {
|
|||||||
|
|
||||||
private let storeList: SecretStoreList
|
private let storeList: SecretStoreList
|
||||||
private let witness: SigningWitness?
|
private let witness: SigningWitness?
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let publicKeyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
private let signatureWriter = OpenSSHSignatureWriter()
|
||||||
private let requestTracer = SigningRequestTracer()
|
private let requestTracer = SigningRequestTracer()
|
||||||
private let certificateHandler = OpenSSHCertificateHandler()
|
private let certificateHandler = OpenSSHCertificateHandler()
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
|
||||||
@ -43,7 +44,7 @@ extension Agent {
|
|||||||
guard data.count > 4 else { return false}
|
guard data.count > 4 else { return false}
|
||||||
let requestTypeInt = data[4]
|
let requestTypeInt = data[4]
|
||||||
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
||||||
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
|
writer.write(SSHAgent.ResponseType.agentFailure.data.lengthAndData)
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -75,8 +76,7 @@ extension Agent {
|
|||||||
response.append(SSHAgent.ResponseType.agentFailure.data)
|
response.append(SSHAgent.ResponseType.agentFailure.data)
|
||||||
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
||||||
}
|
}
|
||||||
let full = OpenSSHKeyWriter().lengthAndData(of: response)
|
return response.lengthAndData
|
||||||
return full
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -92,14 +92,14 @@ extension Agent {
|
|||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
|
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let keyBlob = writer.data(secret: secret)
|
let keyBlob = publicKeyWriter.data(secret: secret)
|
||||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
|
||||||
keyData.append(writer.lengthAndData(of: keyBlob))
|
keyData.append(keyBlob.lengthAndData)
|
||||||
keyData.append(writer.lengthAndData(of: curveData))
|
keyData.append(curveData.lengthAndData)
|
||||||
|
|
||||||
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
|
||||||
keyData.append(writer.lengthAndData(of: certificateData))
|
keyData.append(certificateData.lengthAndData)
|
||||||
keyData.append(writer.lengthAndData(of: name))
|
keyData.append(name.lengthAndData)
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,46 +135,8 @@ extension Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dataToSign = reader.readNextChunk()
|
let dataToSign = reader.readNextChunk()
|
||||||
let signed = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
|
||||||
let derSignature = signed
|
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
|
||||||
|
|
||||||
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
|
||||||
|
|
||||||
// Convert from DER formatted rep to raw (r||s)
|
|
||||||
|
|
||||||
let rawRepresentation: Data
|
|
||||||
switch (secret.algorithm, secret.keySize) {
|
|
||||||
case (.ellipticCurve, 256):
|
|
||||||
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
||||||
case (.ellipticCurve, 384):
|
|
||||||
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
||||||
default:
|
|
||||||
throw AgentError.unsupportedKeyType
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let rawLength = rawRepresentation.count/2
|
|
||||||
// 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(writer.lengthAndData(of: r))
|
|
||||||
signatureChunk.append(writer.lengthAndData(of: s))
|
|
||||||
|
|
||||||
var signedData = Data()
|
|
||||||
var sub = Data()
|
|
||||||
sub.append(writer.lengthAndData(of: curveData))
|
|
||||||
sub.append(writer.lengthAndData(of: signatureChunk))
|
|
||||||
signedData.append(writer.lengthAndData(of: sub))
|
|
||||||
|
|
||||||
if let witness = witness {
|
if let witness = witness {
|
||||||
try await witness.witness(accessTo: secret, from: store, by: provenance)
|
try await witness.witness(accessTo: secret, from: store, by: provenance)
|
||||||
@ -206,7 +168,7 @@ extension Agent {
|
|||||||
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
|
||||||
for store in await storeList.stores {
|
for store in await storeList.stores {
|
||||||
let allMatching = await store.secrets.filter { secret in
|
let allMatching = await store.secrets.filter { secret in
|
||||||
hash == writer.data(secret: secret)
|
hash == publicKeyWriter.data(secret: secret)
|
||||||
}
|
}
|
||||||
if let matching = allMatching.first {
|
if let matching = allMatching.first {
|
||||||
return (store, matching)
|
return (store, matching)
|
||||||
|
@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
|
|||||||
|
|
||||||
### OpenSSH
|
### OpenSSH
|
||||||
|
|
||||||
- ``OpenSSHKeyWriter``
|
- ``OpenSSHPublicKeyWriter``
|
||||||
- ``OpenSSHReader``
|
- ``OpenSSHReader``
|
||||||
|
|
||||||
### Signing Process
|
### Signing Process
|
||||||
|
@ -3,14 +3,12 @@ import Foundation
|
|||||||
/// Type eraser for Secret.
|
/// Type eraser for Secret.
|
||||||
public struct AnySecret: Secret, @unchecked Sendable {
|
public struct AnySecret: Secret, @unchecked Sendable {
|
||||||
|
|
||||||
public let base: Any
|
public let base: any Secret
|
||||||
private let hashable: AnyHashable
|
private let hashable: AnyHashable
|
||||||
private let _id: () -> AnyHashable
|
private let _id: () -> AnyHashable
|
||||||
private let _name: () -> String
|
private let _name: () -> String
|
||||||
private let _algorithm: () -> Algorithm
|
|
||||||
private let _keySize: () -> Int
|
|
||||||
private let _requiresAuthentication: () -> Bool
|
|
||||||
private let _publicKey: () -> Data
|
private let _publicKey: () -> Data
|
||||||
|
private let _attributes: () -> Attributes
|
||||||
|
|
||||||
public init<T>(_ secret: T) where T: Secret {
|
public init<T>(_ secret: T) where T: Secret {
|
||||||
if let secret = secret as? AnySecret {
|
if let secret = secret as? AnySecret {
|
||||||
@ -18,19 +16,15 @@ public struct AnySecret: Secret, @unchecked Sendable {
|
|||||||
hashable = secret.hashable
|
hashable = secret.hashable
|
||||||
_id = secret._id
|
_id = secret._id
|
||||||
_name = secret._name
|
_name = secret._name
|
||||||
_algorithm = secret._algorithm
|
|
||||||
_keySize = secret._keySize
|
|
||||||
_requiresAuthentication = secret._requiresAuthentication
|
|
||||||
_publicKey = secret._publicKey
|
_publicKey = secret._publicKey
|
||||||
|
_attributes = secret._attributes
|
||||||
} else {
|
} else {
|
||||||
base = secret as Any
|
base = secret
|
||||||
self.hashable = secret
|
self.hashable = secret
|
||||||
_id = { secret.id as AnyHashable }
|
_id = { secret.id as AnyHashable }
|
||||||
_name = { secret.name }
|
_name = { secret.name }
|
||||||
_algorithm = { secret.algorithm }
|
|
||||||
_keySize = { secret.keySize }
|
|
||||||
_requiresAuthentication = { secret.requiresAuthentication }
|
|
||||||
_publicKey = { secret.publicKey }
|
_publicKey = { secret.publicKey }
|
||||||
|
_attributes = { secret.attributes }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,22 +36,14 @@ public struct AnySecret: Secret, @unchecked Sendable {
|
|||||||
_name()
|
_name()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var algorithm: Algorithm {
|
|
||||||
_algorithm()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var keySize: Int {
|
|
||||||
_keySize()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var requiresAuthentication: Bool {
|
|
||||||
_requiresAuthentication()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var publicKey: Data {
|
public var publicKey: Data {
|
||||||
_publicKey()
|
_publicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var attributes: Attributes {
|
||||||
|
_attributes()
|
||||||
|
}
|
||||||
|
|
||||||
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
|
||||||
lhs.hashable == rhs.hashable
|
lhs.hashable == rhs.hashable
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import Foundation
|
|||||||
/// Type eraser for SecretStore.
|
/// Type eraser for SecretStore.
|
||||||
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
open class AnySecretStore: SecretStore, @unchecked Sendable {
|
||||||
|
|
||||||
let base: any Sendable
|
let base: any SecretStore
|
||||||
private let _isAvailable: @MainActor @Sendable () -> Bool
|
private let _isAvailable: @MainActor @Sendable () -> Bool
|
||||||
private let _id: @Sendable () -> UUID
|
private let _id: @Sendable () -> UUID
|
||||||
private let _name: @MainActor @Sendable () -> String
|
private let _name: @MainActor @Sendable () -> String
|
||||||
@ -61,27 +61,33 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
|
|||||||
|
|
||||||
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
|
||||||
|
|
||||||
private let _create: @Sendable (String, Bool) async throws -> Void
|
private let _create: @Sendable (String, Attributes) async throws -> Void
|
||||||
private let _delete: @Sendable (AnySecret) async throws -> Void
|
private let _delete: @Sendable (AnySecret) async throws -> Void
|
||||||
private let _update: @Sendable (AnySecret, String) async throws -> Void
|
private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
|
||||||
|
private let _supportedKeyTypes: @Sendable () -> [KeyType]
|
||||||
|
|
||||||
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
|
||||||
_create = { try await secretStore.create(name: $0, requiresAuthentication: $1) }
|
_create = { try await secretStore.create(name: $0, attributes: $1) }
|
||||||
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
|
||||||
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) }
|
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
|
||||||
|
_supportedKeyTypes = { secretStore.supportedKeyTypes }
|
||||||
super.init(secretStore)
|
super.init(secretStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func create(name: String, requiresAuthentication: Bool) async throws {
|
public func create(name: String, attributes: Attributes) async throws {
|
||||||
try await _create(name, requiresAuthentication)
|
try await _create(name, attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: AnySecret) async throws {
|
public func delete(secret: AnySecret) async throws {
|
||||||
try await _delete(secret)
|
try await _delete(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(secret: AnySecret, name: String) async throws {
|
public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
|
||||||
try await _update(secret, name)
|
try await _update(secret, name, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var supportedKeyTypes: [KeyType] {
|
||||||
|
_supportedKeyTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -51,19 +51,17 @@ public extension SecretStore {
|
|||||||
/// Returns the appropriate keychian signature algorithm to use for a given secret.
|
/// Returns the appropriate keychian signature algorithm to use for a given secret.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The secret which will be used for signing.
|
/// - secret: The secret which will be used for signing.
|
||||||
/// - allowRSA: Whether or not RSA key types should be permited.
|
|
||||||
/// - Returns: The appropriate algorithm.
|
/// - Returns: The appropriate algorithm.
|
||||||
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm {
|
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
|
||||||
switch (secret.algorithm, secret.keySize) {
|
switch (secret.keyType.algorithm, secret.keyType.size) {
|
||||||
case (.ellipticCurve, 256):
|
case (.ecdsa, 256):
|
||||||
return .ecdsaSignatureMessageX962SHA256
|
.ecdsaSignatureMessageX962SHA256
|
||||||
case (.ellipticCurve, 384):
|
case (.ecdsa, 384):
|
||||||
return .ecdsaSignatureMessageX962SHA384
|
.ecdsaSignatureMessageX962SHA384
|
||||||
case (.rsa, 1024), (.rsa, 2048):
|
case (.rsa, 2048):
|
||||||
guard allowRSA else { fatalError() }
|
.rsaSignatureMessagePKCS1v15SHA512
|
||||||
return .rsaSignatureMessagePKCS1v15SHA512
|
|
||||||
default:
|
default:
|
||||||
fatalError()
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
|
||||||
|
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
||||||
|
/// - Returns: OpenSSH data.
|
||||||
|
package var lengthAndData: Data {
|
||||||
|
let rawLength = UInt32(count)
|
||||||
|
var endian = rawLength.bigEndian
|
||||||
|
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
||||||
|
/// - Returns: OpenSSH data.
|
||||||
|
package var lengthAndData: Data {
|
||||||
|
Data(utf8).lengthAndData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -6,7 +6,7 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
|
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
|
||||||
private let writer = OpenSSHKeyWriter()
|
private let writer = OpenSSHPublicKeyWriter()
|
||||||
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
|
||||||
|
|
||||||
/// Initializes an OpenSSHCertificateHandler.
|
/// Initializes an OpenSSHCertificateHandler.
|
||||||
@ -40,10 +40,10 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
let curveIdentifier = reader.readNextChunk()
|
let curveIdentifier = reader.readNextChunk()
|
||||||
let publicKey = reader.readNextChunk()
|
let publicKey = reader.readNextChunk()
|
||||||
|
|
||||||
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
|
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
|
||||||
return writer.lengthAndData(of: curveType) +
|
return openSSHIdentifier.lengthAndData +
|
||||||
writer.lengthAndData(of: curveIdentifier) +
|
curveIdentifier.lengthAndData +
|
||||||
writer.lengthAndData(of: publicKey)
|
publicKey.lengthAndData
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -78,14 +78,13 @@ public actor OpenSSHCertificateHandler: Sendable {
|
|||||||
throw OpenSSHCertificateError.parsingFailed
|
throw OpenSSHCertificateError.parsingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) {
|
if certElements.count >= 3 {
|
||||||
|
let certName = Data(certElements[2].utf8)
|
||||||
return (certDecoded, certName)
|
return (certDecoded, certName)
|
||||||
} else if let certName = secret.name.data(using: .utf8) {
|
}
|
||||||
|
let certName = Data(secret.name.utf8)
|
||||||
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
||||||
return (certDecoded, certName)
|
return (certDecoded, certName)
|
||||||
} else {
|
|
||||||
throw OpenSSHCertificateError.parsingFailed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,89 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Generates OpenSSH representations of Secrets.
|
|
||||||
public struct OpenSSHKeyWriter: Sendable {
|
|
||||||
|
|
||||||
/// Initializes the writer.
|
|
||||||
public init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH data payload identifying the secret.
|
|
||||||
/// - Returns: OpenSSH data payload identifying the secret.
|
|
||||||
public func data<SecretType: Secret>(secret: SecretType) -> Data {
|
|
||||||
lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
|
|
||||||
lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
|
|
||||||
lengthAndData(of: secret.publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH string representation of the secret.
|
|
||||||
/// - Returns: OpenSSH string representation of the secret.
|
|
||||||
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
|
|
||||||
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH SHA256 fingerprint string.
|
|
||||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
|
||||||
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
|
||||||
// OpenSSL format seems to strip the padding at the end.
|
|
||||||
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)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates an OpenSSH MD5 fingerprint string.
|
|
||||||
/// - Returns: OpenSSH MD5 fingerprint string.
|
|
||||||
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
|
||||||
Insecure.MD5.hash(data: data(secret: secret))
|
|
||||||
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
|
||||||
.joined(separator: ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension OpenSSHKeyWriter {
|
|
||||||
|
|
||||||
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
|
|
||||||
/// - Parameter data: The data payload.
|
|
||||||
/// - Returns: OpenSSH data.
|
|
||||||
public func lengthAndData(of data: Data) -> Data {
|
|
||||||
let rawLength = UInt32(data.count)
|
|
||||||
var endian = rawLength.bigEndian
|
|
||||||
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fully qualified OpenSSH identifier for the algorithm.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - algorithm: The algorithm to identify.
|
|
||||||
/// - length: The key length of the algorithm.
|
|
||||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
|
||||||
public func curveType(for algorithm: Algorithm, length: Int) -> String {
|
|
||||||
switch algorithm {
|
|
||||||
case .ellipticCurve:
|
|
||||||
return "ecdsa-sha2-nistp" + String(describing: length)
|
|
||||||
case .rsa:
|
|
||||||
// All RSA keys use the same 512 bit hash function, per
|
|
||||||
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
|
|
||||||
return "rsa-sha2-512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The OpenSSH identifier for an algorithm.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - algorithm: The algorithm to identify.
|
|
||||||
/// - length: The key length of the algorithm.
|
|
||||||
/// - Returns: The OpenSSH identifier for the algorithm.
|
|
||||||
private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String {
|
|
||||||
switch algorithm {
|
|
||||||
case .ellipticCurve:
|
|
||||||
return "nistp" + String(describing: length)
|
|
||||||
case .rsa:
|
|
||||||
// All RSA keys use the same 512 bit hash function
|
|
||||||
return "rsa-sha2-512"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Generates OpenSSH representations of the public key sof secrets.
|
||||||
|
public struct OpenSSHPublicKeyWriter: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the writer.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH data payload identifying the secret.
|
||||||
|
/// - Returns: OpenSSH data payload identifying the secret.
|
||||||
|
public func data<SecretType: Secret>(secret: SecretType) -> Data {
|
||||||
|
switch secret.keyType.algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
|
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
|
||||||
|
secret.publicKey.lengthAndData
|
||||||
|
case .rsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
|
openSSHIdentifier(for: secret.keyType).lengthAndData +
|
||||||
|
rsaPublicKeyBlob(secret: secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH string representation of the secret.
|
||||||
|
/// - Returns: OpenSSH string representation of the secret.
|
||||||
|
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
let resolvedComment: String
|
||||||
|
if let comment = secret.publicKeyAttribution {
|
||||||
|
resolvedComment = comment
|
||||||
|
} else {
|
||||||
|
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
|
||||||
|
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: ".")
|
||||||
|
.replacingOccurrences(of: " ", with: "-")
|
||||||
|
resolvedComment = "\(dashedKeyName)@\(dashedHostName)"
|
||||||
|
}
|
||||||
|
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH SHA256 fingerprint string.
|
||||||
|
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||||
|
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
// OpenSSL format seems to strip the padding at the end.
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH MD5 fingerprint string.
|
||||||
|
/// - Returns: OpenSSH MD5 fingerprint string.
|
||||||
|
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
|
||||||
|
Insecure.MD5.hash(data: data(secret: secret))
|
||||||
|
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
|
||||||
|
.joined(separator: ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHPublicKeyWriter {
|
||||||
|
|
||||||
|
/// The fully qualified OpenSSH identifier for the algorithm.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - algorithm: The algorithm to identify.
|
||||||
|
/// - length: The key length of the algorithm.
|
||||||
|
/// - Returns: The OpenSSH identifier for the algorithm.
|
||||||
|
public func openSSHIdentifier(for keyType: KeyType) -> String {
|
||||||
|
switch (keyType.algorithm, keyType.size) {
|
||||||
|
case (.ecdsa, 256), (.ecdsa, 384):
|
||||||
|
"ecdsa-sha2-nistp" + String(describing: keyType.size)
|
||||||
|
case (.rsa, _):
|
||||||
|
"ssh-rsa"
|
||||||
|
default:
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OpenSSHPublicKeyWriter {
|
||||||
|
|
||||||
|
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
|
||||||
|
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
|
||||||
|
// Keychain stores it as a thin ASN.1 wrapper with this format:
|
||||||
|
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
|
||||||
|
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
|
||||||
|
// we only support one key type, and the keychain always gives it in a specific format.
|
||||||
|
let keySize = secret.keyType.size
|
||||||
|
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
|
||||||
|
let length = secret.keyType.size/8
|
||||||
|
let data = secret.publicKey
|
||||||
|
let n = Data(data[8..<(9+length)])
|
||||||
|
let e = Data(data[(2+9+length)...])
|
||||||
|
return e.lengthAndData + n.lengthAndData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -17,9 +17,7 @@ public final class OpenSSHReader {
|
|||||||
let lengthRange = 0..<(UInt32.bitWidth/8)
|
let lengthRange = 0..<(UInt32.bitWidth/8)
|
||||||
let lengthChunk = remaining[lengthRange]
|
let lengthChunk = remaining[lengthRange]
|
||||||
remaining.removeSubrange(lengthRange)
|
remaining.removeSubrange(lengthRange)
|
||||||
let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in
|
let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
|
||||||
return pointer.load(as: UInt32.self)
|
|
||||||
}
|
|
||||||
let length = Int(littleEndianLength.bigEndian)
|
let length = Int(littleEndianLength.bigEndian)
|
||||||
let dataRange = 0..<length
|
let dataRange = 0..<length
|
||||||
let ret = Data(remaining[dataRange])
|
let ret = Data(remaining[dataRange])
|
||||||
|
@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Generates OpenSSH representations of Secrets.
|
||||||
|
public struct OpenSSHSignatureWriter: Sendable {
|
||||||
|
|
||||||
|
/// Initializes the writer.
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an OpenSSH data payload identifying the secret.
|
||||||
|
/// - Returns: OpenSSH data payload identifying the secret.
|
||||||
|
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
|
||||||
|
switch secret.keyType.algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
|
||||||
|
ecdsaSignature(signature, keyType: secret.keyType)
|
||||||
|
case .rsa:
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
|
||||||
|
rsaSignature(signature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension OpenSSHSignatureWriter {
|
||||||
|
|
||||||
|
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
|
||||||
|
let rawLength = rawRepresentation.count/2
|
||||||
|
// 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)
|
||||||
|
signatureChunk.append(s.lengthAndData)
|
||||||
|
var mutSignedData = Data()
|
||||||
|
var sub = Data()
|
||||||
|
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
|
||||||
|
sub.append(signatureChunk.lengthAndData)
|
||||||
|
mutSignedData.append(sub.lengthAndData)
|
||||||
|
return mutSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
func rsaSignature(_ rawRepresentation: Data) -> Data {
|
||||||
|
var mutSignedData = Data()
|
||||||
|
var sub = Data()
|
||||||
|
sub.append("rsa-sha2-512".lengthAndData)
|
||||||
|
sub.append(rawRepresentation.lengthAndData)
|
||||||
|
mutSignedData.append(sub.lengthAndData)
|
||||||
|
return mutSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -6,7 +6,7 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
|
|
||||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
|
||||||
private let directory: String
|
private let directory: String
|
||||||
private let keyWriter = OpenSSHKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
/// Initializes a PublicKeyFileStoreController.
|
/// Initializes a PublicKeyFileStoreController.
|
||||||
public init(homeDirectory: String) {
|
public init(homeDirectory: String) {
|
||||||
@ -32,7 +32,7 @@ public final class PublicKeyFileStoreController: Sendable {
|
|||||||
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
|
||||||
for secret in secrets {
|
for secret in secrets {
|
||||||
let path = publicKeyPath(for: secret)
|
let path = publicKeyPath(for: secret)
|
||||||
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
|
let data = Data(keyWriter.openSSHString(secret: secret).utf8)
|
||||||
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
|
||||||
}
|
}
|
||||||
logger.log("Finished writing public keys")
|
logger.log("Finished writing public keys")
|
||||||
|
@ -20,7 +20,7 @@ import Observation
|
|||||||
|
|
||||||
/// Adds a non-type-erased modifiable SecretStore.
|
/// Adds a non-type-erased modifiable SecretStore.
|
||||||
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
|
||||||
let modifiable = AnySecretStoreModifiable(modifiable: store)
|
let modifiable = AnySecretStoreModifiable(store)
|
||||||
if modifiableStore == nil {
|
if modifiableStore == nil {
|
||||||
modifiableStore = modifiable
|
modifiableStore = modifiable
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Attributes: Sendable, Codable, Hashable {
|
||||||
|
|
||||||
|
/// The type of key involved.
|
||||||
|
public let keyType: KeyType
|
||||||
|
|
||||||
|
/// The authentication requirements for the key. This is simply a description of the option recorded at creation – modifying it doers not modify the key's authentication requirements.
|
||||||
|
public let authentication: AuthenticationRequirement
|
||||||
|
|
||||||
|
/// The string appended to the end of the SSH Public Key.
|
||||||
|
/// If nil, a default value will be used.
|
||||||
|
public var publicKeyAttribution: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
keyType: KeyType,
|
||||||
|
authentication: AuthenticationRequirement,
|
||||||
|
publicKeyAttribution: String? = nil
|
||||||
|
) {
|
||||||
|
self.keyType = keyType
|
||||||
|
self.authentication = authentication
|
||||||
|
self.publicKeyAttribution = publicKeyAttribution
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UnsupportedOptionError: Error {
|
||||||
|
package init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The option specified
|
||||||
|
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
|
||||||
|
|
||||||
|
/// Authentication is not required for usage.
|
||||||
|
case notRequired
|
||||||
|
|
||||||
|
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
|
||||||
|
case presenceRequired
|
||||||
|
|
||||||
|
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
|
||||||
|
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
|
||||||
|
case biometryCurrent
|
||||||
|
|
||||||
|
/// The authentication requirement was not recorded at creation, and is unknown.
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
/// Whether or not the key is known to require authentication.
|
||||||
|
public var required: Bool {
|
||||||
|
self == .presenceRequired || self == .biometryCurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: AuthenticationRequirement {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
@ -5,43 +5,72 @@ public protocol Secret: Identifiable, Hashable, Sendable {
|
|||||||
|
|
||||||
/// A user-facing string identifying the Secret.
|
/// A user-facing string identifying the Secret.
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
/// The algorithm this secret uses.
|
|
||||||
var algorithm: Algorithm { get }
|
|
||||||
/// The key size for the secret.
|
|
||||||
var keySize: Int { get }
|
|
||||||
/// Whether the secret requires authentication before use.
|
|
||||||
var requiresAuthentication: Bool { get }
|
|
||||||
/// The public key data for the secret.
|
/// The public key data for the secret.
|
||||||
var publicKey: Data { get }
|
var publicKey: Data { get }
|
||||||
|
/// The attributes of the key.
|
||||||
|
var attributes: Attributes { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported.
|
public extension Secret {
|
||||||
public enum Algorithm: Hashable, Sendable {
|
|
||||||
|
|
||||||
case ellipticCurve
|
/// The algorithm and key size this secret uses.
|
||||||
|
var keyType: KeyType {
|
||||||
|
attributes.keyType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the secret requires authentication before use.
|
||||||
|
var authenticationRequirement: AuthenticationRequirement {
|
||||||
|
attributes.authentication
|
||||||
|
}
|
||||||
|
/// An attribution string to apply to the generated public key.
|
||||||
|
var publicKeyAttribution: String? {
|
||||||
|
attributes.publicKeyAttribution
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of algorithm the Secret uses.
|
||||||
|
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
|
||||||
|
|
||||||
|
public enum Algorithm: Hashable, Sendable, Codable {
|
||||||
|
case ecdsa
|
||||||
case rsa
|
case rsa
|
||||||
|
}
|
||||||
|
|
||||||
|
public var algorithm: Algorithm
|
||||||
|
public var size: Int
|
||||||
|
|
||||||
|
public init(algorithm: Algorithm, size: Int) {
|
||||||
|
self.algorithm = algorithm
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
/// Initializes the Algorithm with a secAttr representation of an algorithm.
|
/// Initializes the Algorithm with a secAttr representation of an algorithm.
|
||||||
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
|
/// - Parameter secAttr: the secAttr, represented as an NSNumber.
|
||||||
public init(secAttr: NSNumber) {
|
public init?(secAttr: NSNumber, size: Int) {
|
||||||
let secAttrString = secAttr.stringValue as CFString
|
let secAttrString = secAttr.stringValue as CFString
|
||||||
switch secAttrString {
|
switch secAttrString {
|
||||||
case kSecAttrKeyTypeEC:
|
case kSecAttrKeyTypeEC:
|
||||||
self = .ellipticCurve
|
algorithm = .ecdsa
|
||||||
case kSecAttrKeyTypeRSA:
|
case kSecAttrKeyTypeRSA:
|
||||||
self = .rsa
|
algorithm = .rsa
|
||||||
default:
|
default:
|
||||||
fatalError()
|
return nil
|
||||||
|
}
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
public var secAttrKeyType: CFString? {
|
||||||
|
switch algorithm {
|
||||||
|
case .ecdsa:
|
||||||
|
kSecAttrKeyTypeEC
|
||||||
|
case .rsa:
|
||||||
|
kSecAttrKeyTypeRSA
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var secAttrKeyType: CFString {
|
public var description: String {
|
||||||
switch self {
|
"\(algorithm)-\(size)"
|
||||||
case .ellipticCurve:
|
|
||||||
return kSecAttrKeyTypeEC
|
|
||||||
case .rsa:
|
|
||||||
return kSecAttrKeyTypeRSA
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
/// Manages access to Secrets, and performs signature operations on data using those Secrets.
|
||||||
public protocol SecretStore: Identifiable, Sendable {
|
public protocol SecretStore<SecretType>: Identifiable, Sendable {
|
||||||
|
|
||||||
associatedtype SecretType: Secret
|
associatedtype SecretType: Secret
|
||||||
|
|
||||||
@ -41,13 +41,13 @@ public protocol SecretStore: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A SecretStore that the Secretive admin app can modify.
|
/// A SecretStore that the Secretive admin app can modify.
|
||||||
public protocol SecretStoreModifiable: SecretStore {
|
public protocol SecretStoreModifiable<SecretType>: SecretStore {
|
||||||
|
|
||||||
/// Creates a new ``Secret`` in the store.
|
/// Creates a new ``Secret`` in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: The user-facing name for the ``Secret``.
|
/// - name: The user-facing name for the ``Secret``.
|
||||||
/// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret.
|
/// - attributes: A struct describing the options for creating the key.
|
||||||
func create(name: String, requiresAuthentication: Bool) async throws
|
func create(name: String, attributes: Attributes) async throws
|
||||||
|
|
||||||
/// Deletes a Secret in the store.
|
/// Deletes a Secret in the store.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -58,7 +58,10 @@ public protocol SecretStoreModifiable: SecretStore {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - secret: The ``Secret`` to update.
|
/// - secret: The ``Secret`` to update.
|
||||||
/// - name: The new name for the Secret.
|
/// - name: The new name for the Secret.
|
||||||
func update(secret: SecretType, name: String) async throws
|
/// - attributes: The new attributes for the secret.
|
||||||
|
func update(secret: SecretType, name: String, attributes: Attributes) async throws
|
||||||
|
|
||||||
|
var supportedKeyTypes: [KeyType] { get }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import CryptoTokenKit
|
||||||
|
import CryptoKit
|
||||||
|
import SecretKit
|
||||||
|
import os
|
||||||
|
|
||||||
|
extension SecureEnclave {
|
||||||
|
|
||||||
|
public struct CryptoKitMigrator {
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator")
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated.
|
||||||
|
/// - Note: Migration is non-destructive – users can still see and use their keys in older versions of Secretive.
|
||||||
|
@MainActor public func migrate(to store: Store) throws {
|
||||||
|
let privateAttributes = KeychainDictionary([
|
||||||
|
kSecClass: kSecClassKey,
|
||||||
|
kSecAttrKeyType: Constants.oldKeyType,
|
||||||
|
kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
|
||||||
|
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||||||
|
kSecReturnRef: true,
|
||||||
|
kSecMatchLimit: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes: true
|
||||||
|
])
|
||||||
|
var privateUntyped: CFTypeRef?
|
||||||
|
SecItemCopyMatching(privateAttributes, &privateUntyped)
|
||||||
|
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
||||||
|
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
|
||||||
|
var migrated = 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 = attributes[Constants.tokenObjectID] as! Data
|
||||||
|
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
|
||||||
|
// Best guess.
|
||||||
|
let auth: AuthenticationRequirement = String(describing: accessControl)
|
||||||
|
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
|
||||||
|
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
|
||||||
|
let secret = Secret(id: id, 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.")
|
||||||
|
try markMigrated(secret: secret)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.log("Migrating \(name).")
|
||||||
|
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
|
||||||
|
logger.log("Migrated \(name).")
|
||||||
|
try markMigrated(secret: secret)
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
|
if migrated {
|
||||||
|
store.reloadSecrets()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public func markMigrated(secret: Secret) throws {
|
||||||
|
let updateQuery = KeychainDictionary([
|
||||||
|
kSecClass: kSecClassKey,
|
||||||
|
kSecAttrApplicationLabel: secret.id as CFData
|
||||||
|
])
|
||||||
|
|
||||||
|
let newID = secret.id + Constants.migrationMagicNumber
|
||||||
|
let updatedAttributes = KeychainDictionary([
|
||||||
|
kSecAttrApplicationLabel: newID as CFData
|
||||||
|
])
|
||||||
|
|
||||||
|
let status = SecItemUpdate(updateQuery, updatedAttributes)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SecureEnclave.CryptoKitMigrator {
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String
|
||||||
|
public static let migrationMagicNumber = Data("_cryptokit_1".utf8)
|
||||||
|
// https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111
|
||||||
|
public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -8,10 +8,20 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
public let id: Data
|
public let id: Data
|
||||||
public let name: String
|
public let name: String
|
||||||
public let algorithm = Algorithm.ellipticCurve
|
|
||||||
public let keySize = 256
|
|
||||||
public let requiresAuthentication: Bool
|
|
||||||
public let publicKey: Data
|
public let publicKey: Data
|
||||||
|
public let attributes: Attributes
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Data,
|
||||||
|
name: String,
|
||||||
|
publicKey: Data,
|
||||||
|
attributes: Attributes
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.publicKey = publicKey
|
||||||
|
self.attributes = attributes
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,12 +2,13 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
import Security
|
import Security
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import LocalAuthentication
|
@preconcurrency import LocalAuthentication
|
||||||
import SecretKit
|
import SecretKit
|
||||||
|
import os
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave {
|
||||||
|
|
||||||
/// An implementation of Store backed by the Secure Enclave.
|
/// An implementation of Store backed by the Secure Enclave using CryptoKit API.
|
||||||
@Observable public final class Store: SecretStoreModifiable {
|
@Observable public final class Store: SecretStoreModifiable {
|
||||||
|
|
||||||
@MainActor public var secrets: [Secret] = []
|
@MainActor public var secrets: [Secret] = []
|
||||||
@ -23,66 +24,119 @@ extension SecureEnclave {
|
|||||||
loadSecrets()
|
loadSecrets()
|
||||||
Task {
|
Task {
|
||||||
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
|
||||||
await reloadSecretsInternal(notifyAgent: false)
|
reloadSecrets()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Public API
|
// MARK: - Public API
|
||||||
|
|
||||||
public func create(name: String, requiresAuthentication: Bool) async throws {
|
// MARK: SecretStore
|
||||||
var accessError: SecurityError?
|
|
||||||
let flags: SecAccessControlCreateFlags
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
if requiresAuthentication {
|
var context: LAContext
|
||||||
flags = [.privateKeyUsage, .userPresence]
|
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
||||||
|
context = existing.context
|
||||||
} else {
|
} else {
|
||||||
flags = .privateKeyUsage
|
let newContext = LAContext()
|
||||||
|
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
|
||||||
|
context = newContext
|
||||||
|
}
|
||||||
|
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
|
||||||
|
|
||||||
|
let queryAttributes = KeychainDictionary([
|
||||||
|
kSecClass: Constants.keyClass,
|
||||||
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self),
|
||||||
|
kSecReturnAttributes: true,
|
||||||
|
kSecReturnData: true
|
||||||
|
])
|
||||||
|
var untyped: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw KeychainError(statusCode: status)
|
||||||
|
}
|
||||||
|
guard let untypedSafe = untyped as? [CFString: Any] else {
|
||||||
|
throw KeychainError(statusCode: errSecSuccess)
|
||||||
|
}
|
||||||
|
guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data,
|
||||||
|
let keyData = untypedSafe[kSecValueData] as? Data else {
|
||||||
|
throw MissingAttributesError()
|
||||||
|
}
|
||||||
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
|
|
||||||
|
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||||
|
case (.ecdsa, 256):
|
||||||
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||||
|
return try key.signature(for: data).rawRepresentation
|
||||||
|
default:
|
||||||
|
throw UnsupportedAlgorithmError()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
||||||
|
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
||||||
|
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor public func reloadSecrets() {
|
||||||
|
reloadSecretsInternal(notifyAgent: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SecretStoreModifiable
|
||||||
|
|
||||||
|
public func create(name: String, attributes: Attributes) async throws {
|
||||||
|
var accessError: SecurityError?
|
||||||
|
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
|
||||||
|
case .notRequired:
|
||||||
|
[]
|
||||||
|
case .presenceRequired:
|
||||||
|
.userPresence
|
||||||
|
case .biometryCurrent:
|
||||||
|
.biometryCurrentSet
|
||||||
|
case .unknown:
|
||||||
|
fatalError()
|
||||||
}
|
}
|
||||||
let access =
|
let access =
|
||||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
flags,
|
flags,
|
||||||
&accessError) as Any
|
&accessError)
|
||||||
if let error = accessError {
|
if let error = accessError {
|
||||||
throw error.takeRetainedValue() as Error
|
throw error.takeRetainedValue() as Error
|
||||||
}
|
}
|
||||||
|
let dataRep: Data
|
||||||
let attributes = KeychainDictionary([
|
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||||
kSecAttrLabel: name,
|
case (.ecdsa, 256):
|
||||||
kSecAttrKeyType: Constants.keyType,
|
let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
dataRep = created.dataRepresentation
|
||||||
kSecAttrApplicationTag: Constants.keyTag,
|
default:
|
||||||
kSecPrivateKeyAttrs: [
|
throw Attributes.UnsupportedOptionError()
|
||||||
kSecAttrIsPermanent: true,
|
|
||||||
kSecAttrAccessControl: access
|
|
||||||
]
|
|
||||||
])
|
|
||||||
|
|
||||||
var createKeyError: SecurityError?
|
|
||||||
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
|
|
||||||
if let error = createKeyError {
|
|
||||||
throw error.takeRetainedValue() as Error
|
|
||||||
}
|
}
|
||||||
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else {
|
try saveKey(dataRep, name: name, attributes: attributes)
|
||||||
throw KeychainError(statusCode: nil)
|
await reloadSecrets()
|
||||||
}
|
|
||||||
try savePublicKey(publicKey, name: name)
|
|
||||||
await reloadSecretsInternal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func delete(secret: Secret) async throws {
|
public func delete(secret: Secret) async throws {
|
||||||
let deleteAttributes = KeychainDictionary([
|
let deleteAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData
|
kSecAttrService: Constants.keyTag,
|
||||||
|
kSecUseDataProtectionKeychain: true,
|
||||||
|
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self)
|
||||||
])
|
])
|
||||||
let status = SecItemDelete(deleteAttributes)
|
let status = SecItemDelete(deleteAttributes)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
await reloadSecretsInternal()
|
await reloadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(secret: Secret, name: String) async throws {
|
public func update(secret: Secret, name: String, attributes: Attributes) async throws {
|
||||||
let updateQuery = KeychainDictionary([
|
let updateQuery = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: kSecClassKey,
|
||||||
kSecAttrApplicationLabel: secret.id as CFData
|
kSecAttrApplicationLabel: secret.id as CFData
|
||||||
@ -96,56 +150,13 @@ extension SecureEnclave {
|
|||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
await reloadSecretsInternal()
|
await reloadSecrets()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public var supportedKeyTypes: [KeyType] {
|
||||||
let context: LAContext
|
[
|
||||||
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
|
.init(algorithm: .ecdsa, size: 256),
|
||||||
context = existing.context
|
]
|
||||||
} else {
|
|
||||||
let newContext = LAContext()
|
|
||||||
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
|
|
||||||
context = newContext
|
|
||||||
}
|
|
||||||
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
|
|
||||||
let attributes = KeychainDictionary([
|
|
||||||
kSecClass: kSecClassKey,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
|
||||||
kSecAttrApplicationLabel: secret.id as CFData,
|
|
||||||
kSecAttrKeyType: Constants.keyType,
|
|
||||||
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
|
|
||||||
kSecAttrApplicationTag: Constants.keyTag,
|
|
||||||
kSecUseAuthenticationContext: context,
|
|
||||||
kSecReturnRef: true
|
|
||||||
])
|
|
||||||
var untyped: CFTypeRef?
|
|
||||||
let status = SecItemCopyMatching(attributes, &untyped)
|
|
||||||
if status != errSecSuccess {
|
|
||||||
throw KeychainError(statusCode: status)
|
|
||||||
}
|
|
||||||
guard let untypedSafe = untyped else {
|
|
||||||
throw KeychainError(statusCode: errSecSuccess)
|
|
||||||
}
|
|
||||||
let key = untypedSafe as! SecKey
|
|
||||||
var signError: SecurityError?
|
|
||||||
|
|
||||||
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
|
|
||||||
throw SigningError(error: signError)
|
|
||||||
}
|
|
||||||
return signature as Data
|
|
||||||
}
|
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
|
|
||||||
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
|
|
||||||
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func reloadSecrets() async {
|
|
||||||
await reloadSecretsInternal(notifyAgent: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -154,9 +165,7 @@ extension SecureEnclave {
|
|||||||
|
|
||||||
extension SecureEnclave.Store {
|
extension SecureEnclave.Store {
|
||||||
|
|
||||||
/// Reloads all secrets from the store.
|
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) {
|
||||||
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
|
|
||||||
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
|
|
||||||
let before = secrets
|
let before = secrets
|
||||||
secrets.removeAll()
|
secrets.removeAll()
|
||||||
loadSecrets()
|
loadSecrets()
|
||||||
@ -170,75 +179,62 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
/// Loads all secrets from the store.
|
/// Loads all secrets from the store.
|
||||||
@MainActor private func loadSecrets() {
|
@MainActor private func loadSecrets() {
|
||||||
let publicAttributes = KeychainDictionary([
|
let queryAttributes = KeychainDictionary([
|
||||||
kSecClass: kSecClassKey,
|
kSecClass: Constants.keyClass,
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
kSecUseDataProtectionKeychain: true,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
kSecReturnData: true,
|
||||||
kSecReturnRef: true,
|
|
||||||
kSecMatchLimit: kSecMatchLimitAll,
|
kSecMatchLimit: kSecMatchLimitAll,
|
||||||
kSecReturnAttributes: true
|
kSecReturnAttributes: true
|
||||||
])
|
])
|
||||||
var publicUntyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(publicAttributes, &publicUntyped)
|
SecItemCopyMatching(queryAttributes, &untyped)
|
||||||
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let privateAttributes = KeychainDictionary([
|
let wrapped: [SecureEnclave.Secret] = typed.compactMap {
|
||||||
kSecClass: kSecClassKey,
|
do {
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
guard let attributesData = $0[kSecAttrGeneric] as? Data,
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
let idString = $0[kSecAttrAccount] as? String else {
|
||||||
kSecReturnRef: true,
|
throw MissingAttributesError()
|
||||||
kSecMatchLimit: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes: true
|
|
||||||
])
|
|
||||||
var privateUntyped: CFTypeRef?
|
|
||||||
SecItemCopyMatching(privateAttributes, &privateUntyped)
|
|
||||||
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
|
|
||||||
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
|
|
||||||
let id = next[kSecAttrApplicationLabel] as! Data
|
|
||||||
partialResult[id] = next
|
|
||||||
}
|
}
|
||||||
let authNotRequiredAccessControl: SecAccessControl =
|
let id = Data(idString.utf8)
|
||||||
SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
|
||||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
let keyData = $0[kSecValueData] as! Data
|
||||||
[.privateKeyUsage],
|
let publicKey: Data
|
||||||
nil)!
|
switch (attributes.keyType.algorithm, attributes.keyType.size) {
|
||||||
|
case (.ecdsa, 256):
|
||||||
let wrapped: [SecureEnclave.Secret] = publicTyped.map {
|
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
publicKey = key.publicKey.x963Representation
|
||||||
let id = $0[kSecAttrApplicationLabel] as! Data
|
default:
|
||||||
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
throw UnsupportedAlgorithmError()
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any]
|
}
|
||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
|
||||||
let privateKey = privateMapped[id]
|
} catch {
|
||||||
let requiresAuth: Bool
|
return nil
|
||||||
if let authRequirements = privateKey?[kSecAttrAccessControl] {
|
|
||||||
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality.
|
|
||||||
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl
|
|
||||||
} else {
|
|
||||||
requiresAuth = false
|
|
||||||
}
|
}
|
||||||
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
|
|
||||||
}
|
}
|
||||||
secrets.append(contentsOf: wrapped)
|
secrets.append(contentsOf: wrapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves a public key.
|
/// Saves a public key.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - publicKey: The public key to save.
|
/// - key: The data representation key to save.
|
||||||
/// - name: A user-facing name for the key.
|
/// - name: A user-facing name for the key.
|
||||||
private func savePublicKey(_ publicKey: SecKey, name: String) throws {
|
/// - attributes: Attributes of the key.
|
||||||
let attributes = KeychainDictionary([
|
/// - 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.
|
||||||
kSecClass: kSecClassKey,
|
func saveKey(_ key: Data, name: String, attributes: Attributes) throws {
|
||||||
kSecAttrKeyType: SecureEnclave.Constants.keyType,
|
let attributes = try JSONEncoder().encode(attributes)
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPublic,
|
let keychainAttributes = KeychainDictionary([
|
||||||
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag,
|
kSecClass: Constants.keyClass,
|
||||||
kSecValueRef: publicKey,
|
kSecAttrService: Constants.keyTag,
|
||||||
kSecAttrIsPermanent: true,
|
kSecUseDataProtectionKeychain: true,
|
||||||
kSecReturnData: true,
|
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
kSecAttrLabel: name
|
kSecAttrAccount: UUID().uuidString,
|
||||||
|
kSecValueData: key,
|
||||||
|
kSecAttrLabel: name,
|
||||||
|
kSecAttrGeneric: attributes
|
||||||
])
|
])
|
||||||
let status = SecItemAdd(attributes, nil)
|
let status = SecItemAdd(keychainAttributes, nil)
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
throw KeychainError(statusCode: status)
|
throw KeychainError(statusCode: status)
|
||||||
}
|
}
|
||||||
@ -246,12 +242,14 @@ extension SecureEnclave.Store {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SecureEnclave {
|
extension SecureEnclave.Store {
|
||||||
|
|
||||||
public enum Constants {
|
enum Constants {
|
||||||
public static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
static let keyClass = kSecClassGenericPassword as String
|
||||||
public static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String
|
static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
|
||||||
static let unauthenticatedThreshold: TimeInterval = 0.05
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UnsupportedAlgorithmError: Error {}
|
||||||
|
struct MissingAttributesError: Error {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -8,10 +8,8 @@ extension SmartCard {
|
|||||||
|
|
||||||
public let id: Data
|
public let id: Data
|
||||||
public let name: String
|
public let name: String
|
||||||
public let algorithm: Algorithm
|
|
||||||
public let keySize: Int
|
|
||||||
public let requiresAuthentication: Bool = false
|
|
||||||
public let publicKey: Data
|
public let publicKey: Data
|
||||||
|
public var attributes: Attributes
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,14 +56,6 @@ extension SmartCard {
|
|||||||
|
|
||||||
// MARK: Public API
|
// MARK: Public API
|
||||||
|
|
||||||
public func create(name: String) throws {
|
|
||||||
fatalError("Keys must be created on the smart card.")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func delete(secret: Secret) throws {
|
|
||||||
fatalError("Keys must be deleted on the smart card.")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
|
||||||
guard let tokenID = await state.tokenID else { fatalError() }
|
guard let tokenID = await state.tokenID else { fatalError() }
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
@ -87,7 +79,8 @@ extension SmartCard {
|
|||||||
}
|
}
|
||||||
let key = untypedSafe as! SecKey
|
let key = untypedSafe as! SecKey
|
||||||
var signError: SecurityError?
|
var signError: SecurityError?
|
||||||
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else {
|
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
|
||||||
|
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
|
||||||
throw SigningError(error: signError)
|
throw SigningError(error: signError)
|
||||||
}
|
}
|
||||||
return signature as Data
|
return signature as Data
|
||||||
@ -161,16 +154,19 @@ extension SmartCard.Store {
|
|||||||
var untyped: CFTypeRef?
|
var untyped: CFTypeRef?
|
||||||
SecItemCopyMatching(attributes, &untyped)
|
SecItemCopyMatching(attributes, &untyped)
|
||||||
guard let typed = untyped as? [[CFString: Any]] else { return }
|
guard let typed = untyped as? [[CFString: Any]] else { return }
|
||||||
let wrapped = typed.map {
|
let wrapped: [SecretType] = typed.compactMap {
|
||||||
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
|
||||||
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
let tokenID = $0[kSecAttrApplicationLabel] as! Data
|
||||||
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber)
|
let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
|
||||||
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
let keySize = $0[kSecAttrKeySizeInBits] as! Int
|
||||||
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
let publicKeyRef = $0[kSecValueRef] as! SecKey
|
||||||
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
|
||||||
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
|
||||||
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
let publicKey = publicKeyAttributes[kSecValueData] as! Data
|
||||||
return SmartCard.Secret(id: tokenID, name: name, algorithm: algorithm, keySize: keySize, publicKey: publicKey)
|
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
|
||||||
}
|
}
|
||||||
state.secrets.append(contentsOf: wrapped)
|
state.secrets.append(contentsOf: wrapped)
|
||||||
}
|
}
|
||||||
@ -185,3 +181,9 @@ extension TKTokenWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SmartCard {
|
||||||
|
|
||||||
|
public struct UnsupportKeyType: Error {}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -35,7 +35,7 @@ import CryptoKit
|
|||||||
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
#expect(stubWriter.data == Constants.Responses.requestFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func signature() async throws {
|
@Test func ecdsaSignature() async throws {
|
||||||
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
|
||||||
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
|
||||||
_ = requestReader.readNextChunk()
|
_ = requestReader.readNextChunk()
|
||||||
|
@ -45,20 +45,15 @@ extension Stub {
|
|||||||
let privateData = (privateAttributes[kSecValueData] as! Data)
|
let privateData = (privateAttributes[kSecValueData] as! Data)
|
||||||
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
|
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
|
||||||
print(secret)
|
print(secret)
|
||||||
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
|
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
|
||||||
guard !shouldThrow else {
|
guard !shouldThrow else {
|
||||||
throw NSError(domain: "test", code: 0, userInfo: nil)
|
throw NSError(domain: "test", code: 0, userInfo: nil)
|
||||||
}
|
}
|
||||||
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([
|
let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
|
||||||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
return try privateKey.signature(for: data).rawRepresentation
|
||||||
kSecAttrKeySizeInBits: secret.keySize,
|
|
||||||
kSecAttrKeyClass: kSecAttrKeyClassPrivate
|
|
||||||
])
|
|
||||||
, nil)!
|
|
||||||
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
|
||||||
@ -79,24 +74,22 @@ extension Stub {
|
|||||||
|
|
||||||
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
|
struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
|
||||||
|
|
||||||
let id = UUID().uuidString.data(using: .utf8)!
|
let id = Data(UUID().uuidString.utf8)
|
||||||
let name = UUID().uuidString
|
let name = UUID().uuidString
|
||||||
let algorithm = Algorithm.ellipticCurve
|
let attributes: Attributes
|
||||||
|
|
||||||
let keySize: Int
|
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let requiresAuthentication = false
|
let requiresAuthentication = false
|
||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
|
|
||||||
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
init(keySize: Int, publicKey: Data, privateKey: Data) {
|
||||||
self.keySize = keySize
|
self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
"""
|
"""
|
||||||
Key Size \(keySize)
|
Key Size \(keyType.size)
|
||||||
Private: \(privateKey.base64EncodedString())
|
Private: \(privateKey.base64EncodedString())
|
||||||
Public: \(publicKey.base64EncodedString())
|
Public: \(publicKey.base64EncodedString())
|
||||||
"""
|
"""
|
||||||
|
@ -4,15 +4,16 @@ import Testing
|
|||||||
@testable import SecureEnclaveSecretKit
|
@testable import SecureEnclaveSecretKit
|
||||||
@testable import SmartCardSecretKit
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
|
|
||||||
@Suite struct AnySecretTests {
|
@Suite struct AnySecretTests {
|
||||||
|
|
||||||
@Test func eraser() {
|
@Test func eraser() {
|
||||||
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!)
|
let data = Data(UUID().uuidString.utf8)
|
||||||
|
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
|
||||||
let erased = AnySecret(secret)
|
let erased = AnySecret(secret)
|
||||||
#expect(erased.id == secret.id as AnyHashable)
|
#expect(erased.id == secret.id as AnyHashable)
|
||||||
#expect(erased.name == secret.name)
|
#expect(erased.name == secret.name)
|
||||||
#expect(erased.algorithm == secret.algorithm)
|
#expect(erased.keyType == secret.keyType)
|
||||||
#expect(erased.keySize == secret.keySize)
|
|
||||||
#expect(erased.publicKey == secret.publicKey)
|
#expect(erased.publicKey == secret.publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,9 +4,9 @@ import Testing
|
|||||||
@testable import SecureEnclaveSecretKit
|
@testable import SecureEnclaveSecretKit
|
||||||
@testable import SmartCardSecretKit
|
@testable import SmartCardSecretKit
|
||||||
|
|
||||||
@Suite struct OpenSSHWriterTests {
|
@Suite struct OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
let writer = OpenSSHKeyWriter()
|
let writer = OpenSSHPublicKeyWriter()
|
||||||
|
|
||||||
@Test func ecdsa256MD5Fingerprint() {
|
@Test func ecdsa256MD5Fingerprint() {
|
||||||
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
|
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
|
||||||
@ -18,7 +18,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func ecdsa256PublicKey() {
|
@Test func ecdsa256PublicKey() {
|
||||||
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
|
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
|
||||||
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")
|
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsa256Hash() {
|
@Test func ecdsa256Hash() {
|
||||||
@ -35,7 +35,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func ecdsa384PublicKey() {
|
@Test func ecdsa384PublicKey() {
|
||||||
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
|
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
|
||||||
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")
|
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func ecdsa384Hash() {
|
@Test func ecdsa384Hash() {
|
||||||
@ -44,11 +44,11 @@ import Testing
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OpenSSHWriterTests {
|
extension OpenSSHPublicKeyWriterTests {
|
||||||
|
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!)
|
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)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!)
|
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"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -12,7 +12,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
@MainActor private let storeList: SecretStoreList = {
|
@MainActor private let storeList: SecretStoreList = {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
list.add(store: SecureEnclave.Store())
|
let cryptoKit = SecureEnclave.Store()
|
||||||
|
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||||
|
try? migrator.migrate(to: cryptoKit)
|
||||||
|
list.add(store: cryptoKit)
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
@ -46,6 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
updater.update
|
updater.update
|
||||||
} onChange: { [updater, notifier] in
|
} onChange: { [updater, notifier] in
|
||||||
Task {
|
Task {
|
||||||
|
guard !updater.testBuild else { return }
|
||||||
await notifier.notify(update: updater.update!) { release in
|
await notifier.notify(update: updater.update!) { release in
|
||||||
await updater.ignore(release: release)
|
await updater.ignore(release: release)
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,7 @@ final class Notifier: Sendable {
|
|||||||
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
|
||||||
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
|
||||||
notificationContent.interruptionLevel = .timeSensitive
|
notificationContent.interruptionLevel = .timeSensitive
|
||||||
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication {
|
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
|
||||||
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
|
||||||
}
|
}
|
||||||
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */; };
|
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */; };
|
||||||
50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; };
|
50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; };
|
||||||
50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; };
|
50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; };
|
||||||
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; };
|
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; };
|
||||||
@ -98,7 +98,7 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.swift; sourceTree = "<group>"; };
|
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = "<group>"; };
|
||||||
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
|
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
|
||||||
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
|
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
|
||||||
@ -246,7 +246,7 @@
|
|||||||
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
|
||||||
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
|
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
|
||||||
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
|
50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
|
||||||
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */,
|
2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
|
||||||
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
|
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
|
||||||
506772C82425BB8500034DED /* NoStoresView.swift */,
|
506772C82425BB8500034DED /* NoStoresView.swift */,
|
||||||
50153E1F250AFCB200525160 /* UpdateView.swift */,
|
50153E1F250AFCB200525160 /* UpdateView.swift */,
|
||||||
@ -430,7 +430,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */,
|
2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
|
||||||
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
|
||||||
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
|
||||||
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import Cocoa
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SecretKit
|
import SecretKit
|
||||||
import SecureEnclaveSecretKit
|
import SecureEnclaveSecretKit
|
||||||
@ -10,7 +9,10 @@ extension EnvironmentValues {
|
|||||||
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
|
||||||
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
@MainActor fileprivate static let _secretStoreList: SecretStoreList = {
|
||||||
let list = SecretStoreList()
|
let list = SecretStoreList()
|
||||||
list.add(store: SecureEnclave.Store())
|
let cryptoKit = SecureEnclave.Store()
|
||||||
|
let migrator = SecureEnclave.CryptoKitMigrator()
|
||||||
|
try? migrator.migrate(to: cryptoKit)
|
||||||
|
list.add(store: cryptoKit)
|
||||||
list.add(store: SmartCard.Store())
|
list.add(store: SmartCard.Store())
|
||||||
return list
|
return list
|
||||||
}()
|
}()
|
||||||
|
@ -56,7 +56,7 @@ struct ShellConfigurationController {
|
|||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!)
|
handle.write(Data("\n# Secretive Config\n\(shellInstructions.text)\n".utf8))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,11 +9,13 @@ extension Preview {
|
|||||||
|
|
||||||
let id = UUID().uuidString
|
let id = UUID().uuidString
|
||||||
let name: String
|
let name: String
|
||||||
let algorithm = Algorithm.ellipticCurve
|
let publicKey = Data(UUID().uuidString.utf8)
|
||||||
let keySize = 256
|
var attributes: Attributes {
|
||||||
let requiresAuthentication: Bool = false
|
Attributes(
|
||||||
let publicKey = UUID().uuidString.data(using: .utf8)!
|
keyType: .init(algorithm: .ecdsa, size: 256),
|
||||||
|
authentication: .presenceRequired,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -58,6 +60,11 @@ extension Preview {
|
|||||||
let id = UUID()
|
let id = UUID()
|
||||||
var name: String { "Modifiable Preview Store" }
|
var name: String { "Modifiable Preview Store" }
|
||||||
let secrets: [Secret]
|
let secrets: [Secret]
|
||||||
|
var supportedKeyTypes: [KeyType] {
|
||||||
|
[
|
||||||
|
.init(algorithm: .ecdsa, size: 256),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
init(secrets: [Secret]) {
|
init(secrets: [Secret]) {
|
||||||
self.secrets = secrets
|
self.secrets = secrets
|
||||||
@ -83,13 +90,13 @@ extension Preview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func create(name: String, requiresAuthentication: Bool) throws {
|
func create(name: String, attributes: Attributes) throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(secret: Preview.Secret) throws {
|
func delete(secret: Preview.Secret) throws {
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(secret: Preview.Secret, name: String) throws {
|
func update(secret: Preview.Secret, name: String, attributes: Attributes) throws {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 640, minHeight: 320)
|
.frame(minWidth: 640, minHeight: 320)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
// toolbarItem(updateNoticeView, id: "update")
|
toolbarItem(updateNoticeView, id: "update")
|
||||||
toolbarItem(runningOrRunSetupView, id: "setup")
|
toolbarItem(runningOrRunSetupView, id: "setup")
|
||||||
toolbarItem(appPathNoticeView, id: "appPath")
|
toolbarItem(appPathNoticeView, id: "appPath")
|
||||||
toolbarItem(newItemView, id: "new")
|
toolbarItem(newItemView, id: "new")
|
||||||
|
@ -7,244 +7,123 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
|
|||||||
@Binding var showing: Bool
|
@Binding var showing: Bool
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var requiresAuthentication = true
|
@State private var keyAttribution = ""
|
||||||
|
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
|
||||||
|
@State private var keyType: KeyType?
|
||||||
|
@State var advanced = false
|
||||||
|
|
||||||
|
private var authenticationOptions: [AuthenticationRequirement] {
|
||||||
|
if advanced || authenticationRequirement == .biometryCurrent {
|
||||||
|
[.presenceRequired, .notRequired, .biometryCurrent]
|
||||||
|
} else {
|
||||||
|
[.presenceRequired, .notRequired]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
|
||||||
|
ForEach(authenticationOptions) { option in
|
||||||
|
HStack {
|
||||||
|
switch option {
|
||||||
|
case .notRequired:
|
||||||
|
Image(systemName: "bell")
|
||||||
|
Text(.createSecretNotifyTitle)
|
||||||
|
case .presenceRequired:
|
||||||
|
Image(systemName: "lock")
|
||||||
|
Text(.createSecretRequireAuthenticationTitle)
|
||||||
|
case .biometryCurrent:
|
||||||
|
Image(systemName: "lock.trianglebadge.exclamationmark.fill")
|
||||||
|
Text("Current Biometrics")
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
switch authenticationRequirement {
|
||||||
|
case .notRequired:
|
||||||
|
Text(.createSecretNotifyDescription)
|
||||||
|
case .presenceRequired:
|
||||||
|
Text(.createSecretRequireAuthenticationDescription)
|
||||||
|
case .biometryCurrent:
|
||||||
|
Text("Require authentication with current set of biometrics.")
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if authenticationRequirement == .biometryCurrent {
|
||||||
|
Text("If you change your biometric settings in _any way_, including adding a new fingerprint, this key will no longer be accessible.")
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if advanced {
|
||||||
|
Section {
|
||||||
VStack {
|
VStack {
|
||||||
|
Picker("Key Type", selection: $keyType) {
|
||||||
|
ForEach(store.supportedKeyTypes, id: \.self) { option in
|
||||||
|
Text(String(describing: option))
|
||||||
|
.tag(option)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
TextField("Key Attribution", text: $keyAttribution, prompt: Text("test@example.com"))
|
||||||
|
Text("This shows at the end of your public key.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
Toggle("Advanced", isOn: $advanced)
|
||||||
HStack {
|
.toggleStyle(.button)
|
||||||
Text(.createSecretTitle)
|
|
||||||
.font(.largeTitle)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
Button(.createSecretCancelButton, role: .cancel) {
|
||||||
HStack {
|
|
||||||
Text(.createSecretNameLabel)
|
|
||||||
TextField(String(localized: .createSecretNamePlaceholder), text: $name)
|
|
||||||
.focusable()
|
|
||||||
}
|
|
||||||
ThumbnailPickerView(items: [
|
|
||||||
ThumbnailPickerView.Item(value: true, name: .createSecretRequireAuthenticationTitle, description: .createSecretRequireAuthenticationDescription, thumbnail: AuthenticationView()),
|
|
||||||
ThumbnailPickerView.Item(value: false, name: .createSecretNotifyTitle,
|
|
||||||
description: .createSecretNotifyDescription,
|
|
||||||
thumbnail: NotificationView())
|
|
||||||
], selection: $requiresAuthentication)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(.createSecretCancelButton) {
|
|
||||||
showing = false
|
showing = false
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.cancelAction)
|
|
||||||
Button(.createSecretCreateButton, action: save)
|
Button(.createSecretCreateButton, action: save)
|
||||||
.disabled(name.isEmpty)
|
.disabled(name.isEmpty)
|
||||||
.keyboardShortcut(.defaultAction)
|
|
||||||
}
|
}
|
||||||
}.padding()
|
.padding()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
keyType = store.supportedKeyTypes.first
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
|
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
|
||||||
Task {
|
Task {
|
||||||
try! await store.create(name: name, requiresAuthentication: requiresAuthentication)
|
try! await store.create(
|
||||||
|
name: name,
|
||||||
|
attributes: .init(
|
||||||
|
keyType: keyType!,
|
||||||
|
authentication: authenticationRequirement,
|
||||||
|
publicKeyAttribution: attribution
|
||||||
|
)
|
||||||
|
)
|
||||||
showing = false
|
showing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ThumbnailPickerView<ValueType: Hashable>: View {
|
#Preview {
|
||||||
|
|
||||||
private let items: [Item<ValueType>]
|
|
||||||
@Binding var selection: ValueType
|
|
||||||
|
|
||||||
init(items: [ThumbnailPickerView<ValueType>.Item<ValueType>], selection: Binding<ValueType>) {
|
|
||||||
self.items = items
|
|
||||||
_selection = selection
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
ForEach(items) { item in
|
|
||||||
VStack(alignment: .leading, spacing: 15) {
|
|
||||||
item.thumbnail
|
|
||||||
.frame(height: 200)
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(lineWidth: item.value == selection ? 15 : 0))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
|
||||||
Text(item.name)
|
|
||||||
.bold()
|
|
||||||
Text(item.description)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 250)
|
|
||||||
.onTapGesture {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
selection = item.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ThumbnailPickerView {
|
|
||||||
|
|
||||||
struct Item<InnerValueType: Hashable>: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let value: InnerValueType
|
|
||||||
let name: LocalizedStringResource
|
|
||||||
let description: LocalizedStringResource
|
|
||||||
let thumbnail: AnyView
|
|
||||||
|
|
||||||
init<ViewType: View>(value: InnerValueType, name: LocalizedStringResource, description: LocalizedStringResource, thumbnail: ViewType) {
|
|
||||||
self.value = value
|
|
||||||
self.name = name
|
|
||||||
self.description = description
|
|
||||||
self.thumbnail = AnyView(thumbnail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor @Observable class SystemBackground {
|
|
||||||
|
|
||||||
static let shared = SystemBackground()
|
|
||||||
var image: NSImage?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
|
|
||||||
image = NSImage(contentsOf: imageURL)
|
|
||||||
} else {
|
|
||||||
image = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SystemBackgroundView: View {
|
|
||||||
|
|
||||||
let anchor: UnitPoint
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let image = SystemBackground.shared.image {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.scaleEffect(3, anchor: anchor)
|
|
||||||
.clipped()
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
} else {
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(Color(.systemPurple))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AuthenticationView: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
SystemBackgroundView(anchor: .center)
|
|
||||||
GeometryReader { geometry in
|
|
||||||
VStack {
|
|
||||||
Image(systemName: "touchid")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.foregroundColor(Color(.systemRed))
|
|
||||||
Text(verbatim: "Touch ID Prompt")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.redacted(reason: .placeholder)
|
|
||||||
VStack {
|
|
||||||
Text(verbatim: "Touch ID Detail prompt.Detail two.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(verbatim: "Touch ID Detail prompt.Detail two.")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
.redacted(reason: .placeholder)
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.frame(width: geometry.size.width, height: 20, alignment: .center)
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.frame(width: geometry.size.width, height: 20, alignment: .center)
|
|
||||||
.foregroundColor(Color(.unemphasizedSelectedContentBackgroundColor))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(width: 150)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 15)
|
|
||||||
.foregroundStyle(.ultraThickMaterial)
|
|
||||||
)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NotificationView: View {
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
SystemBackgroundView(anchor: .topTrailing)
|
|
||||||
VStack {
|
|
||||||
Rectangle()
|
|
||||||
.background(Color.clear)
|
|
||||||
.foregroundStyle(.thinMaterial)
|
|
||||||
.frame(height: 35)
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
HStack {
|
|
||||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(verbatim: "Secretive")
|
|
||||||
.font(.title)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(verbatim: "Secretive wants to sign")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
|
||||||
}.padding()
|
|
||||||
.redacted(reason: .placeholder)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 15)
|
|
||||||
.foregroundStyle(.ultraThickMaterial)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
|
|
||||||
struct CreateSecretView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
|
||||||
AuthenticationView().environment(\.colorScheme, .dark)
|
|
||||||
AuthenticationView().environment(\.colorScheme, .light)
|
|
||||||
NotificationView().environment(\.colorScheme, .dark)
|
|
||||||
NotificationView().environment(\.colorScheme, .light)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
57
Sources/Secretive/Views/EditSecretView.swift
Normal file
57
Sources/Secretive/Views/EditSecretView.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SecretKit
|
||||||
|
|
||||||
|
struct EditSecretView<StoreType: SecretStoreModifiable>: View {
|
||||||
|
|
||||||
|
let store: StoreType
|
||||||
|
let secret: StoreType.SecretType
|
||||||
|
let dismissalBlock: (_ renamed: Bool) -> ()
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var publicKeyAttribution: String
|
||||||
|
|
||||||
|
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
|
||||||
|
self.store = store
|
||||||
|
self.secret = secret
|
||||||
|
self.dismissalBlock = dismissalBlock
|
||||||
|
name = secret.name
|
||||||
|
publicKeyAttribution = secret.publicKeyAttribution ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
TextField("Key Attribution", text: $publicKeyAttribution, prompt: Text("test@example.com"))
|
||||||
|
Text("This shows at the end of your public key.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button(.renameRenameButton, action: rename)
|
||||||
|
.disabled(name.isEmpty)
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
Button(.renameCancelButton) {
|
||||||
|
dismissalBlock(false)
|
||||||
|
}.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rename() {
|
||||||
|
var attributes = secret.attributes
|
||||||
|
if !publicKeyAttribution.isEmpty {
|
||||||
|
attributes.publicKeyAttribution = publicKeyAttribution
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
try? await store.update(secret: secret, name: name, attributes: attributes)
|
||||||
|
dismissalBlock(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,52 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SecretKit
|
|
||||||
|
|
||||||
struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
|
|
||||||
|
|
||||||
@State var store: StoreType
|
|
||||||
let secret: StoreType.SecretType
|
|
||||||
var dismissalBlock: (_ renamed: Bool) -> ()
|
|
||||||
|
|
||||||
@State private var newName = ""
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Image(nsImage: NSApplication.shared.applicationIconImage)
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 64, height: 64)
|
|
||||||
.padding()
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Text(.renameTitle(secretName: secret.name))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
TextField(secret.name, text: $newName).focusable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(.renameRenameButton, action: rename)
|
|
||||||
.disabled(newName.count == 0)
|
|
||||||
.keyboardShortcut(.return)
|
|
||||||
Button(.renameCancelButton) {
|
|
||||||
dismissalBlock(false)
|
|
||||||
}.keyboardShortcut(.cancelAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(minWidth: 400)
|
|
||||||
.onExitCommand {
|
|
||||||
dismissalBlock(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rename() {
|
|
||||||
Task {
|
|
||||||
try? await store.update(secret: secret, name: newName)
|
|
||||||
dismissalBlock(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,7 +5,7 @@ struct SecretDetailView<SecretType: Secret>: View {
|
|||||||
|
|
||||||
let secret: SecretType
|
let secret: SecretType
|
||||||
|
|
||||||
private let keyWriter = OpenSSHKeyWriter()
|
private let keyWriter = OpenSSHPublicKeyWriter()
|
||||||
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
|
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -30,19 +30,9 @@ struct SecretDetailView<SecretType: Secret>: View {
|
|||||||
.frame(minHeight: 200, maxHeight: .infinity)
|
.frame(minHeight: 200, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
var dashedKeyName: String {
|
|
||||||
secret.name.replacingOccurrences(of: " ", with: "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
var dashedHostName: String {
|
|
||||||
["secretive", Host.current().localizedName, "local"]
|
|
||||||
.compactMap { $0 }
|
|
||||||
.joined(separator: ".")
|
|
||||||
.replacingOccurrences(of: " ", with: "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
var keyString: String {
|
var keyString: String {
|
||||||
keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)")
|
keyWriter.openSSHString(secret: secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ struct SecretListItemView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationLink(value: secret) {
|
NavigationLink(value: secret) {
|
||||||
if secret.requiresAuthentication {
|
if secret.authenticationRequirement.required {
|
||||||
HStack {
|
HStack {
|
||||||
Text(secret.name)
|
Text(secret.name)
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -39,14 +39,16 @@ struct SecretListItemView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
if store is AnySecretStoreModifiable {
|
if store is AnySecretStoreModifiable {
|
||||||
Button(action: { isRenaming = true }) {
|
Button(action: { isRenaming = true }) {
|
||||||
Text(.secretListRenameButton)
|
Image(systemName: "pencil")
|
||||||
|
Text(.secretListEditButton)
|
||||||
}
|
}
|
||||||
Button(action: { isDeleting = true }) {
|
Button(action: { isDeleting = true }) {
|
||||||
|
Image(systemName: "trash")
|
||||||
Text(.secretListDeleteButton)
|
Text(.secretListDeleteButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.popover(isPresented: showingPopup) {
|
.sheet(isPresented: showingPopup) {
|
||||||
if let modifiable = store as? AnySecretStoreModifiable {
|
if let modifiable = store as? AnySecretStoreModifiable {
|
||||||
if isDeleting {
|
if isDeleting {
|
||||||
DeleteSecretView(store: modifiable, secret: secret) { deleted in
|
DeleteSecretView(store: modifiable, secret: secret) { deleted in
|
||||||
@ -56,7 +58,7 @@ struct SecretListItemView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isRenaming {
|
} else if isRenaming {
|
||||||
RenameSecretView(store: modifiable, secret: secret) { renamed in
|
EditSecretView(store: modifiable, secret: secret) { renamed in
|
||||||
isRenaming = false
|
isRenaming = false
|
||||||
if renamed {
|
if renamed {
|
||||||
renamedSecret(secret)
|
renamedSecret(secret)
|
||||||
|
Loading…
Reference in New Issue
Block a user