Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add verify and account options for signing updates #2074

Merged
merged 2 commits into from
Jan 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions generate_appcast/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Foundation
import ArgumentParser

func loadPrivateKeys(_ privateDSAKey: SecKey?, _ privateEdString: String?) -> PrivateKeys {
func loadPrivateKeys(_ account: String, _ privateDSAKey: SecKey?, _ privateEdString: String?) -> PrivateKeys {
var privateEdKey: Data?
var publicEdKey: Data?
var item: CFTypeRef?
Expand All @@ -28,14 +28,14 @@ func loadPrivateKeys(_ privateDSAKey: SecKey?, _ privateEdString: String?) -> Pr
let res = SecItemCopyMatching([
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "https://sparkle-project.org",
kSecAttrAccount as String: "ed25519",
kSecAttrAccount as String: account,
kSecAttrProtocol as String: kSecAttrProtocolSSH,
kSecReturnData as String: kCFBooleanTrue!,
] as CFDictionary, &item)
if res == errSecSuccess, let encoded = item as? Data, let data = Data(base64Encoded: encoded) {
keys = data
} else {
print("Warning: Private key not found in the Keychain (\(res)). Please run the generate_keys tool")
print("Warning: Private key for account \(account) not found in the Keychain (\(res)). Please run the generate_keys tool")
}
}

Expand All @@ -54,6 +54,9 @@ struct GenerateAppcast: ParsableCommand {
static let DEFAULT_MAX_NEW_VERSIONS_IN_FEED = 5
static let DEFAULT_MAXIMUM_DELTAS = 5

@Option(help: ArgumentHelp("The account name in your keychain associated with your private EdDSA (ed25519) key to use for signing new updates."))
var account : String = "ed25519"

@Option(name: .customShort("s"), help: ArgumentHelp("The private EdDSA string (128 characters). If not specified, the private EdDSA key will be read from the Keychain instead.", valueName: "private-EdDSA-key"))
var privateEdString : String?

Expand Down Expand Up @@ -205,7 +208,7 @@ struct GenerateAppcast: ParsableCommand {
privateDSAKey = nil
}

let keys = loadPrivateKeys(privateDSAKey, privateEdString)
let keys = loadPrivateKeys(account, privateDSAKey, privateEdString)

do {
let allUpdates = try makeAppcast(archivesSourceDir: archivesSourceDir, cacheDirectory: GenerateAppcast.cacheDirectory, keys: keys, versions: versions, maximumDeltas: maximumDeltas, deltaCompressionModeDescription: deltaCompression, deltaCompressionLevel: deltaCompressionLevel, verbose: verbose)
Expand Down
25 changes: 14 additions & 11 deletions generate_keys/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import ArgumentParser

let PRIVATE_KEY_LABEL = "Private key for signing Sparkle updates"

private func commonKeychainItemAttributes() -> [String: Any] {
private func commonKeychainItemAttributes(account: String) -> [String: Any] {
/// Attributes used for both adding a new item and matching an existing one.
return [
/// The type of the item (a generic password).
Expand All @@ -22,7 +22,7 @@ private func commonKeychainItemAttributes() -> [String: Any] {
kSecAttrService as String: "https://sparkle-project.org",

/// The account name for the item (in this case, the key type).
kSecAttrAccount as String: "ed25519",
kSecAttrAccount as String: account,

/// The protocol used by the service (not actually used, so we claim SSH).
kSecAttrProtocol as String: kSecAttrProtocolSSH as String,
Expand All @@ -40,9 +40,9 @@ private func failure(_ message: String) -> Never {
exit(1)
}

func findKeyPair() -> Data? {
func findKeyPair(account: String) -> Data? {
var item: CFTypeRef?
let res = SecItemCopyMatching(commonKeychainItemAttributes().merging([
let res = SecItemCopyMatching(commonKeychainItemAttributes(account: account).merging([
/// Return a matched item's value as a CFData object.
kSecReturnData as String: kCFBooleanTrue!,
], uniquingKeysWith: { $1 }) as CFDictionary, &item)
Expand Down Expand Up @@ -102,8 +102,8 @@ func generateKeyPair() -> (publicEdKey: Data, privateEdKey: Data) {
return (Data(publicEdKey), Data(privateEdKey))
}

func storeKeyPair(publicEdKey: Data, privateEdKey: Data) {
let query = commonKeychainItemAttributes().merging([
func storeKeyPair(account: String, publicEdKey: Data, privateEdKey: Data) {
let query = commonKeychainItemAttributes(account: account).merging([
/// Mark the new item as sensitive (requires keychain password to export - e.g. a private key).
kSecAttrIsSensitive as String: kCFBooleanTrue!,

Expand Down Expand Up @@ -157,6 +157,9 @@ func printNewPublicKeyUsage(_ publicKey: Data) {
}

struct GenerateKeys: ParsableCommand {
@Option(help: ArgumentHelp("The account name to use when generating or looking up keys from your keychain. If this is not specified, a default global account is used instead. We recommend using different accounts for different organizations."))
var account: String = "ed25519"

@Flag(name: .customShort("p"), help: ArgumentHelp("Looks up and just prints the existing public key stored in the Keychain."))
var lookUpPublicKey: Bool = false

Expand Down Expand Up @@ -203,7 +206,7 @@ struct GenerateKeys: ParsableCommand {
func run() throws {
if lookUpPublicKey {
/// Lookup mode - print just the pubkey and exit
if let keyPair = findKeyPair() {
if let keyPair = findKeyPair(account: account) {
let pubKey = keyPair[64...]
print(pubKey.base64EncodedString())
} else {
Expand All @@ -216,7 +219,7 @@ struct GenerateKeys: ParsableCommand {
failure("private-key-file already exists: \(exportURL.path)")
}

guard let keyPair = findKeyPair() else {
guard let keyPair = findKeyPair(account: account) else {
failure("No existing signing key found!")
}

Expand Down Expand Up @@ -248,12 +251,12 @@ struct GenerateKeys: ParsableCommand {
let publicKey = privateAndPublicKey[64...]
let privateKey = privateAndPublicKey[0..<64]

storeKeyPair(publicEdKey: publicKey, privateEdKey: privateKey)
storeKeyPair(account: account, publicEdKey: publicKey, privateEdKey: privateKey)

printNewPublicKeyUsage(publicKey)
} else {
/// Default mode - find an existing public key and print its usage, or generate new keys
if let keyPair = findKeyPair() {
if let keyPair = findKeyPair(account: account) {
let pubKey = keyPair[64...]
print("""
A pre-existing signing key was found. This is how it should appear in your Info.plist:
Expand All @@ -266,7 +269,7 @@ struct GenerateKeys: ParsableCommand {
print("Generating a new signing key. This may take a moment, depending on your machine.")

let (pubKey, privKey) = generateKeyPair()
storeKeyPair(publicEdKey: pubKey, privateEdKey: privKey)
storeKeyPair(account: account, publicEdKey: pubKey, privateEdKey: privKey)

printNewPublicKeyUsage(pubKey)
}
Expand Down
61 changes: 51 additions & 10 deletions sign_update/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ import Foundation
import Security
import ArgumentParser

func findKeysInKeychain() throws -> (Data, Data) {
func findKeysInKeychain(account: String) throws -> (Data, Data) {
var item: CFTypeRef?
let res = SecItemCopyMatching([
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "https://sparkle-project.org",
kSecAttrAccount as String: "ed25519",
kSecAttrAccount as String: account,
kSecAttrProtocol as String: kSecAttrProtocolSSH,
kSecReturnData as String: kCFBooleanTrue!,
] as CFDictionary, &item)
if res == errSecSuccess, let encoded = item as? Data, let keys = Data(base64Encoded: encoded) {
return (keys[0..<64], keys[64..<(64+32)])
} else if res == errSecItemNotFound {
print("ERROR! Signing key not found. Please run generate_keys tool first or provide key with -f <private_key_file> or -s <private_key> parameter.")
print("ERROR! Signing key not found for account \(account). Please run generate_keys tool first or provide key with -f <private_key_file> or -s <private_key> parameter.")
} else if res == errSecAuthFailed {
print("ERROR! Access denied. Can't get keys from the keychain.")
print("Go to Keychain Access.app, lock the login keychain, then unlock it again.")
Expand Down Expand Up @@ -70,23 +70,36 @@ func edSignature(data: Data, publicEdKey: Data, privateEdKey: Data) -> String {
}

struct SignUpdate: ParsableCommand {
@Argument(help: "The update archive, delta update, or package (pkg) to sign.")
var updatePath: String
@Option(help: ArgumentHelp("The account name in your keychain associated with your private EdDSA (ed25519) key to use for signing the update."))
var account: String = "ed25519"

@Flag(help: ArgumentHelp("Verify that the update is signed correctly. If this is set, a second argument <verify-signature> denoting the signature must be passed after the <update-path>.", valueName: "verify"))
var verify: Bool = false

@Option(name: .customShort("s"), help: ArgumentHelp("The private EdDSA (ed25519) key.", valueName: "private-key"))
var privateKey: String?

@Option(name: .customShort("f"), help: ArgumentHelp("Path to the file containing the private EdDSA (ed25519) key.", valueName: "private-key-file"))
var privateKeyFile: String?

@Argument(help: "The update archive, delta update, or package (pkg) to sign or verify.")
var updatePath: String

@Argument(help: "The signature to verify when --verify is passed.")
var verifySignature: String?

static var configuration: CommandConfiguration = CommandConfiguration(
abstract: "Sign an update using your private EdDSA (ed25519) key.",
discussion: "The private EdDSA key is automatically read from the Keychain if no <private-key> or <private-key-file> is specified.\n\nThis tool will output an EdDSA signature and length attributes to use for your update's appcast item enclosure.")
abstract: "Sign or verify an update using your EdDSA (ed25519) keys.",
discussion: "The EdDSA keys are automatically read from the Keychain if no <private-key> or <private-key-file> is specified.\n\nWhen signing, this tool will output an EdDSA signature and length attributes to use for your update's appcast item enclosure.")

func validate() throws {
guard privateKey == nil || privateKeyFile == nil else {
throw ValidationError("Both -s <private-key> and -f <private-key-file> options cannot be provided.")
}

guard !verify || verifySignature != nil else {
throw ValidationError("<verify-signature> must be passed as a second argument after <update-path> if --verify is passed.")
}
}

func run() throws {
Expand All @@ -97,12 +110,40 @@ struct SignUpdate: ParsableCommand {
} else if let privateKeyFile = privateKeyFile {
(priv, pub) = try findKeys(inFile: privateKeyFile)
} else {
(priv, pub) = try findKeysInKeychain()
(priv, pub) = try findKeysInKeychain(account: account)
}

let data = try Data.init(contentsOf: URL.init(fileURLWithPath: updatePath), options: .mappedIfSafe)
let sig = edSignature(data: data, publicEdKey: pub, privateEdKey: priv)
print("sparkle:edSignature=\"\(sig)\" length=\"\(data.count)\"")
if verify {
// Verify the signature
guard let verifySignature = verifySignature else {
print("Error: failed to unwrap verifySignature, which is unexpected")
throw ExitCode.failure
}

guard let signatureData = Data(base64Encoded: verifySignature, options: .ignoreUnknownCharacters) else {
print("Error: failed to decode base64 signature: \(verifySignature)")
throw ExitCode.failure
}

let signatureBytes = Array(signatureData)
guard signatureBytes.count == 64 else {
print("Error: signature passed in has an invalid byte count.")
throw ExitCode.failure
}

let dataBytes = Array(data)
let publicKeyBytes = Array(pub)

if ed25519_verify(signatureBytes, dataBytes, data.count, publicKeyBytes) == 0 {
print("Error: failed to pass signing verification.")
throw ExitCode.failure
}
} else {
// Sign the update
let sig = edSignature(data: data, publicEdKey: pub, privateEdKey: priv)
print("sparkle:edSignature=\"\(sig)\" length=\"\(data.count)\"")
}
}
}

Expand Down