from: github.com/remko/age-plugin-se

This commit is contained in:
2023-03-09 22:52:40 +08:00
parent 57fac2893c
commit 71927745a8
22 changed files with 2720 additions and 2 deletions

19
Documentation/Design.md Normal file
View File

@@ -0,0 +1,19 @@
# Design Notes
This document contains notes about design choices made in the plugin.
## SecureEnclave APIs: Security vs CryptoKit
Apple provides 2 APIs for communicating with the Secure Enclave: [through the Security Framework](https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/protecting_keys_with_the_secure_enclave), and [through CryptoKit](https://developer.apple.com/documentation/cryptokit/secureenclave).
The advantage of the Security framework is that it has been around for a long time, and is accessible from within Objective-C, which means you could write the entire plugin in e.g. Go, and use cgo out of the box for the few functions that need to talk to the Secure Enclave. However, using this API comes with disadvantages:
- The Security API requires your app to have special entitlements. This means you need to have an Apple Developer Certificate to build and run the app locally, and it could not be distributed through Homebrew core.
- Because the app needs entitlements, it cannot be distributed as a single binary, but has to be wrapped in a macOS App structure. You can still link from somewhere in the executable path to the binary inside the app structure, but the app would need to live somewhere else (possibly hidden, since the plugin is useless as a runnable standalone UI application)
- All keys are created through the Keychain. This means creating a key has a side effect of putting something in your keychain. You could probably export the private data from the keychain into an age identity file, and delete the key immediately after creating it, but something may go wrong, and you would be leaking data. Alternatively, you could choose to leave the keys in the Keychain, but then the plugin would need to manage looking up keys by tag, and push the complexity of keeping multiple keys via a plugin-specific CLI to the user. It's also not clear what happens during backup restore of the keychain, as there have been reports of confusing behavior there.
- Even for the few lines of Objective-C that are necessary, it would require a good understanding of how memory management works in the Objective-C API in order to not run memory corruption or leaks. I'm not that confident I can get this 100% right.
The CryptoKit framework does not have any of these disadvantages: it accesses the Secure Enclave directly, using a very simple API that does not require special entitlements. However, CryptoKit is only accessible through Swift, which means you can't directly write your plugin in a language such as Go and use cgo out of the box. You could probably still create a small library in Swift that does the necessary calls, and wrap that in an Objective-C or C API, and use Cgo on that, but that's a lot of moving parts and complexity for a simple plugin such as this one. Besides, the only potential part that could be reused in the plugin would be the Age stanza parsing, which not only just 50 lines of code, but isn't exposed by age anyway. CryptoKit comes with all the necessary cryptographic primitives that are necessary for the rest of the plugin.
For these reasons, I have chosen to keep the plugin as simple as possible for both me and the user, so it is implemented entirely in Swift using the CryptoKit API.

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20">
<title>Build - passing</title>
<defs>
<linearGradient id="workflow-fill" x1="50%" y1="0%" x2="50%" y2="100%">
<stop stop-color="#444D56" offset="0%"></stop>
<stop stop-color="#24292E" offset="100%"></stop>
</linearGradient>
<linearGradient id="state-fill" x1="50%" y1="0%" x2="50%" y2="100%">
<stop stop-color="#34D058" offset="0%"></stop>
<stop stop-color="#28A745" offset="100%"></stop>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<g font-family="&#39;DejaVu Sans&#39;,Verdana,Geneva,sans-serif" font-size="11">
<path id="workflow-bg" d="M0,3 C0,1.3431 1.3552,0 3.02702703,0 L65,0 L65,20 L3.02702703,20 C1.3552,20 0,18.6569 0,17 L0,3 Z" fill="url(#workflow-fill)" fill-rule="nonzero"></path>
<text fill="#010101" fill-opacity=".3">
<tspan x="6" y="15" aria-hidden="true">Coverage</tspan>
</text>
<text fill="#FFFFFF">
<tspan x="6" y="14">Coverage</tspan>
</text>
</g>
<g transform="translate(65)" font-family="&#39;DejaVu Sans&#39;,Verdana,Geneva,sans-serif" font-size="11">
<path d="M0 0h46.939C48.629 0 40 1.343 40 3v14c0 1.657-1.37 3-3.061 3H0V0z" id="state-bg" fill="url(#state-fill)" fill-rule="nonzero"></path>
<text fill="#010101" fill-opacity=".3" aria-hidden="true">
<tspan x="7" y="15">{COVERAGE}</tspan>
</text>
<text fill="#FFFFFF">
<tspan x="7" y="14">{COVERAGE}</tspan>
</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Remko Tronçon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

118
Makefile Normal file
View File

@@ -0,0 +1,118 @@
PREFIX ?= /usr/local
AGE ?= age
ifeq ($(RELEASE),1)
SWIFT_BUILD_FLAGS=-c release --disable-sandbox
endif
ifeq ($(COVERAGE),1)
SWIFT_TEST_FLAGS=--enable-code-coverage
endif
# E.g. Tests.RecipientV1Tests/testRecipient
ifneq ($(TEST_FILTER),)
SWIFT_TEST_FLAGS := $(SWIFT_TEST_FLAGS) --filter $(TEST_FILTER)
endif
ifeq ($(OS),Windows_NT)
UNAME_S=Windows
else
UNAME_S=$(shell uname -s)
endif
VERSION ?= $(shell cat Sources/CLI.swift | grep '^let version' | sed -e "s/.*\"v\\(.*\\)\".*/\\1/")
BUILD_DIR = $(shell swift build $(SWIFT_BUILD_FLAGS) --show-bin-path)
PACKAGE_ARCHS = arm64-apple-macosx x86_64-apple-macosx
ECHO = echo
ifneq ($(UNAME_S),Darwin)
ECHO = /usr/bin/echo -e
endif
.PHONY: all
all:
swift build $(SWIFT_BUILD_FLAGS)
.PHONY: package
ifeq ($(UNAME_S),Darwin)
package:
for arch in $(PACKAGE_ARCHS); do swift build -c release --triple $$arch; done
lipo -create -output .build/age-plugin-se $(foreach arch, $(PACKAGE_ARCHS), \
$(shell swift build -c release --triple $(arch) --show-bin-path)/age-plugin-se)
cd .build && ditto -c -k age-plugin-se age-plugin-se-v$(VERSION)-macos.zip
else
package:
swift build -c release --static-swift-stdlib
tar czf .build/age-plugin-se-v$(VERSION)-$(shell uname -m)-linux.tgz -C $(shell swift build -c release --show-bin-path) age-plugin-se
endif
.PHONY: test
test:
swift test $(SWIFT_TEST_FLAGS)
ifeq ($(COVERAGE),1)
coverage_total=`cat $$(swift test --show-codecov-path) | jq '.data[0].totals.lines.percent' | xargs printf "%.0f%%"` && (cat Documentation/img/coverage.svg | sed -e "s/{COVERAGE}/$$coverage_total/" > .build/coverage.svg)
(command -v llvm-coverage-viewer > /dev/null) && llvm-coverage-viewer --json $$(swift test --show-codecov-path) --output .build/coverage.html
@cat $$(swift test --show-codecov-path) | jq '.data[0].totals.lines.percent' | xargs printf "Test coverage (lines): %.2f%%\\n"
@cat $$(swift test --show-codecov-path) | jq -r '.data[0].files[] | "\(.filename)\t\(.summary.lines.percent)\t\(.summary.lines.covered)\t\(.summary.lines.count)"' | grep -v "Tests.swift" | sed -r -e 's/.*\/(Sources\/|Tests\/)/\1/' | xargs printf " %s: %.2f %% (%d/%d)\\n"
endif
.PHONY: lint
lint:
swift-format lint --recursive --strict .
.PHONY: install
install:
install -d $(PREFIX)/bin
install $(BUILD_DIR)/age-plugin-se $(PREFIX)/bin
.PHONY: smoke-test
smoke-test:
PATH="$(BUILD_DIR):$$PATH" && \
$(ECHO) '\xf0\x9f\x94\x91 Generating key...' && \
recipient=`age-plugin-se keygen --access-control=any-biometry -o key.txt | sed -e "s/Public key: //"` && \
$(ECHO) '\xf0\x9f\x94\x92 Encrypting...' && \
($(ECHO) '\xe2\x9c\x85 \x53\x75\x63\x63\x65\x73\x73' | $(AGE) --encrypt --recipient $$recipient -o secret.txt.age) && \
$(ECHO) '\xf0\x9f\x94\x93 Decrypting...' && \
$(AGE) --decrypt -i key.txt secret.txt.age && \
rm -f key.txt secret.txt.age
.PHONY: smoke-test-noninteractive
smoke-test-noninteractive:
PATH="$(BUILD_DIR):$$PATH" && \
$(ECHO) '\xf0\x9f\x94\x91 Generating key...' && \
recipient=`age-plugin-se keygen --access-control=none -o key.txt | sed -e "s/Public key: //"` && \
$(ECHO) '\xf0\x9f\x94\x92 Encrypting...' && \
($(ECHO) '\xe2\x9c\x85 \x53\x75\x63\x63\x65\x73\x73' | $(AGE) --encrypt --recipient $$recipient -o secret.txt.age) && \
$(ECHO) '\xf0\x9f\x94\x93 Decrypting...' && \
$(AGE) --decrypt -i key.txt secret.txt.age && \
rm -f key.txt secret.txt.age
.PHONY: smoke-test-encrypt
smoke-test-encrypt:
PATH="$(BUILD_DIR):$$PATH" && \
$(ECHO) '\xf0\x9f\x94\x92 Encrypting...' && \
($(ECHO) "test" | $(AGE) --encrypt --recipient age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp -o secret.txt.age) && \
$(ECHO) '\xe2\x9c\x85 \x53\x75\x63\x63\x65\x73\x73' && \
rm -f secret.txt.age
.PHONY: gen-manual-tests
gen-manual-tests:
-rm -rf gen-manual-tests
mkdir -p manual-tests
PATH="$(BUILD_DIR):$$PATH" && set -e && \
for control in none passcode current-biometry any-biometry current-biometry-and-passcode any-biometry-and-passcode any-biometry-or-passcode; do \
recipient=`age-plugin-se keygen --access-control=$$control -o manual-tests/key.$$control.txt | sed -e "s/Public key: //"`;\
($(ECHO) '\xe2\x9c\x85 \x53\x75\x63\x63\x65\x73\x73' | $(AGE) --encrypt --recipient $$recipient -o manual-tests/secret.txt.$$control.age); \
done
.PHONY: run-manual-tests
run-manual-tests:
PATH="$(BUILD_DIR):$$PATH" && set -e && \
for control in none passcode any-biometry current-biometry-and-passcode any-biometry-and-passcode any-biometry-or-passcode; do \
$(ECHO) "\\xf0\\x9f\\x94\\x93 Decrypting '$$control'..." && \
$(AGE) --decrypt -i manual-tests/key.$$control.txt manual-tests/secret.txt.$$control.age; \
$(ECHO) "\n-----\n"; \
done
.PHONY: clean
clean:
-rm -rf .build manual-tests

36
Package.swift Normal file
View File

@@ -0,0 +1,36 @@
// swift-tools-version: 5.7
import PackageDescription
// Technically, the dependencies don't need the platform conditional.
// However, I like to keep the dependencies out of the build entirely on macOS.
// Unfortunately, this also means Package.resolved isn't stable.
var packageDependencies: [Package.Dependency] {
#if os(Linux) || os(Windows)
return [.package(url: "https://github.com/apple/swift-crypto.git", "2.0.0"..<"3.0.0")]
#else
return []
#endif
}
var targetDependencies: [Target.Dependency] {
#if os(Linux) || os(Windows)
return [
.product(
name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux, .windows]))
]
#else
return []
#endif
}
let package = Package(
name: "AgeSecureEnclavePlugin",
platforms: [.macOS(.v13)],
dependencies: packageDependencies,
targets: [
.executableTarget(name: "age-plugin-se", dependencies: targetDependencies, path: "Sources"),
.testTarget(name: "Tests", dependencies: ["age-plugin-se"], path: "Tests"),
]
)

View File

@@ -1,3 +1,98 @@
# age-plugin-se
> ⚠️ **This plugin is still under review. Feedback welcome!**
From: https://github.com/remko/age-plugin-se
# Age plugin for Apple's Secure Enclave
[![Build](https://github.com/remko/age-plugin-se/actions/workflows/build.yml/badge.svg)](https://github.com/remko/age-plugin-se/actions/workflows/build.yml)
`age-plugin-se` is a plugin for [age](https://age-encryption.org), enabling encryption using [Apple's Secure Enclave](https://support.apple.com/en-gb/guide/security/sec59b0b31ff/web).
$ age-plugin-se keygen --access-control=any-biometry -o key.txt
Public key: age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp
$ tar cvz ~/data | age -r age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<div align="center">
<img src="https://raw.githubusercontent.com/remko/age-plugin-se/main/Documentation/img/screenshot-biometry.png" alt="Biometry prompt" width=350/>
</div>
## Requirements
To generate identity files and decrypt encrypted files, you need a Mac running macOS 13 (Ventura) with a
Secure Enclave processor.
For encrypting files, you need macOS 13 (Ventura), Linux, or Windows. A Secure Enclave processor is not necessary.
## Installation
### Homebrew
> Coming when v1.0 is released
### Pre-built binary
1. Download a binary from [the releases page](https://github.com/remko/age-plugin-se/releases)
2. Extract the package
3. (Windows only) Download and install [Swift](https://www.swift.org/download/)
4. (macOS only) Trust `age-plugin-se` once by Control-clicking the file in Finder, choosing *Open*,
and confirming trust
5. Move `age-plugin-se` to somewhere on your executable path (e.g. `/usr/local/bin`)
### Building from source
1. (non-macOS only) Download and install [Swift](https://www.swift.org/download/)
2. Clone [the source code repository](https://github.com/remko/age-plugin-se) or
get a source package from [the releases page](https://github.com/remko/age-plugin-se/releases)
3. Build the plugin
make
4. Install the plugin
sudo make install PREFIX=/usr/local
## Usage
age-plugin-se keygen [-o OUTPUT] [--access-control ACCESS_CONTROL]
Options:
-o, --output OUTPUT Write the result to the file at path OUTPUT
--access-control ACCESS_CONTROL Access control for using the generated key.
Supported values: none, passcode,
any-biometry, any-biometry-and-passcode, any-biometry-or-passcode,
current-biometry, current-biometry-and-passcode
Default: any-biometry-or-passcode.
When using current biometry, adding or removing a fingerprint stops the
key from working. Removing an added fingerprint enables the key again.
## Development
Build the plugin
make
Make sure `.build/debug/age-plugin-se` is in your execution path (or softlinked from a folder in your path), so `age` can find the plugin.
### Tests
To run the unit tests:
make test
To get a coverage report of the unit test:
make test COVERAGE=1
If you want an HTML version of the coverage report, make sure [llvm-coverage-viewer](https://www.npmjs.com/package/llvm-coverage-viewer) is installed.
To run a smoke test:
make smoke-test

36
Sources/Base64.swift Normal file
View File

@@ -0,0 +1,36 @@
import Foundation
extension Data {
init?(base64RawEncoded: String) {
if base64RawEncoded.hasSuffix("=") {
return nil
}
var str = base64RawEncoded
switch base64RawEncoded.count % 4 {
case 2:
str += "=="
case 3:
str += "="
default:
()
}
guard let data = Data(base64Encoded: str) else {
return nil
}
self = data
}
var base64RawEncodedData: Data {
var s = base64EncodedData(options: [
Base64EncodingOptions.lineLength64Characters, Base64EncodingOptions.endLineWithLineFeed,
])
if let pi = s.firstIndex(of: Character("=").asciiValue!) {
s = Data(s[s.startIndex..<pi])
}
return s
}
var base64RawEncodedString: String {
return String(data: base64RawEncodedData, encoding: .utf8)!
}
}

235
Sources/Bech32.swift Normal file
View File

@@ -0,0 +1,235 @@
// Spec: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
// Modified version of https://github.com/0xDEADP00L/Bech32
// Copyright 2018 Evolution Group Limited
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import Foundation
/// Bech32 checksum implementation
public class Bech32 {
private let gen: [UInt32] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3]
/// Bech32 checksum delimiter
private let checksumMarker: String = "1"
/// Bech32 character set for encoding
private let encCharset: Data = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".data(using: .utf8)!
/// Bech32 character set for decoding
private let decCharset: [Int8] = [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
]
private func convertBits(from: Int, to: Int, pad: Bool, idata: Data) throws -> Data {
var acc: Int = 0
var bits: Int = 0
let maxv: Int = (1 << to) - 1
let maxAcc: Int = (1 << (from + to - 1)) - 1
var odata = Data()
for ibyte in idata {
acc = ((acc << from) | Int(ibyte)) & maxAcc
bits += from
while bits >= to {
bits -= to
odata.append(UInt8((acc >> bits) & maxv))
}
}
if pad {
if bits != 0 {
odata.append(UInt8((acc << (to - bits)) & maxv))
}
} else if bits >= from || ((acc << (to - bits)) & maxv) != 0 {
throw DecodingError.bitsConversionFailed
}
return odata
}
/// Find the polynomial with value coefficients mod the generator as 30-bit.
private func polymod(_ values: Data) -> UInt32 {
var chk: UInt32 = 1
for v in values {
let top = (chk >> 25)
chk = (chk & 0x1ffffff) << 5 ^ UInt32(v)
for i: UInt8 in 0..<5 {
chk ^= ((top >> i) & 1) == 0 ? 0 : gen[Int(i)]
}
}
return chk
}
/// Expand a HRP for use in checksum computation.
private func expandHrp(_ hrp: String) -> Data {
guard let hrpBytes = hrp.data(using: .utf8) else { return Data() }
var result = Data(repeating: 0x00, count: hrpBytes.count * 2 + 1)
for (i, c) in hrpBytes.enumerated() {
result[i] = c >> 5
result[i + hrpBytes.count + 1] = c & 0x1f
}
result[hrp.count] = 0
return result
}
/// Verify checksum
private func verifyChecksum(hrp: String, checksum: Data) -> Bool {
var data = expandHrp(hrp)
data.append(checksum)
return polymod(data) == 1
}
/// Create checksum
private func createChecksum(hrp: String, values: Data) -> Data {
var enc = expandHrp(hrp)
enc.append(values)
enc.append(Data(repeating: 0x00, count: 6))
let mod: UInt32 = polymod(enc) ^ 1
var ret: Data = Data(repeating: 0x00, count: 6)
for i in 0..<6 {
ret[i] = UInt8((mod >> (5 * (5 - i))) & 31)
}
return ret
}
/// Encode Bech32 string
private func encodeBech32(_ hrp: String, values: Data) -> String {
let checksum = createChecksum(hrp: hrp, values: values)
var combined = values
combined.append(checksum)
guard let hrpBytes = hrp.data(using: .utf8) else { return "" }
var ret = hrpBytes
ret.append("1".data(using: .utf8)!)
for i in combined {
ret.append(encCharset[Int(i)])
}
return String(data: ret, encoding: .utf8) ?? ""
}
/// Decode Bech32 string
public func decodeBech32(_ str: String) throws -> (hrp: String, checksum: Data) {
guard let strBytes = str.data(using: .utf8) else {
throw DecodingError.nonUTF8String
}
var lower: Bool = false
var upper: Bool = false
for c in strBytes {
// printable range
if c < 33 || c > 126 {
throw DecodingError.nonPrintableCharacter
}
// 'a' to 'z'
if c >= 97 && c <= 122 {
lower = true
}
// 'A' to 'Z'
if c >= 65 && c <= 90 {
upper = true
}
}
if lower && upper {
throw DecodingError.invalidCase
}
guard let pos = str.range(of: checksumMarker, options: .backwards)?.lowerBound else {
throw DecodingError.noChecksumMarker
}
let intPos: Int = str.distance(from: str.startIndex, to: pos)
guard intPos >= 1 else {
throw DecodingError.incorrectHrpSize
}
guard intPos + 7 <= str.count else {
throw DecodingError.incorrectChecksumSize
}
let vSize: Int = str.count - 1 - intPos
var values: Data = Data(repeating: 0x00, count: vSize)
for i in 0..<vSize {
let c = strBytes[i + intPos + 1]
let decInt = decCharset[Int(c)]
if decInt == -1 {
throw DecodingError.invalidCharacter
}
values[i] = UInt8(decInt)
}
let hrp = String(str[..<pos]).lowercased()
guard verifyChecksum(hrp: hrp, checksum: values) else {
throw DecodingError.checksumMismatch
}
return (hrp, Data(values[..<(vSize - 6)]))
}
public func encode(hrp: String, data: Data) -> String {
let isUpper = hrp[hrp.startIndex].isUppercase
let result = encodeBech32(
isUpper ? hrp.lowercased() : hrp,
values: try! self.convertBits(from: 8, to: 5, pad: true, idata: data))
return isUpper ? result.uppercased() : result
}
public func decode(_ str: String) throws -> (hrp: String, data: Data) {
let isUpper = str[str.startIndex].isUppercase
let result = try decodeBech32(isUpper ? str.lowercased() : str)
return (
isUpper ? result.hrp.uppercased() : result.hrp,
try convertBits(from: 5, to: 8, pad: false, idata: result.checksum)
)
}
}
extension Bech32 {
public enum DecodingError: LocalizedError {
case nonUTF8String
case nonPrintableCharacter
case invalidCase
case noChecksumMarker
case incorrectHrpSize
case incorrectChecksumSize
case invalidCharacter
case checksumMismatch
case bitsConversionFailed
public var errorDescription: String? {
switch self {
case .bitsConversionFailed:
return "Failed to perform bits conversion"
case .checksumMismatch:
return "Checksum doesn't match"
case .incorrectChecksumSize:
return "Checksum size too low"
case .incorrectHrpSize:
return "Human-readable-part is too small or empty"
case .invalidCase:
return "String contains mixed case characters"
case .invalidCharacter:
return "Invalid character met on decoding"
case .noChecksumMarker:
return "Checksum delimiter not found"
case .nonPrintableCharacter:
return "Non printable character in input string"
case .nonUTF8String:
return "String cannot be decoded by utf8 decoder"
}
}
}
}

173
Sources/CLI.swift Normal file
View File

@@ -0,0 +1,173 @@
import Foundation
let version = "v0.0.3"
@main
struct CLI {
static func main() {
do {
let plugin = Plugin(crypto: CryptoKitCrypto(), stream: StandardIOStream())
let options = try Options.parse(CommandLine.arguments)
switch options.command {
case .help:
print(Options.help)
case .version:
print(version)
case .keygen:
let result = try plugin.generateKey(
accessControl: options.accessControl.keyAccessControl, now: Date())
if let outputFile = options.output {
FileManager.default.createFile(
atPath: FileManager.default.currentDirectoryPath + "/" + outputFile,
contents: result.0.data(using: .utf8),
attributes: [.posixPermissions: 0o600]
)
print("Public key: \(result.1)")
} else {
print(result.0)
}
case .plugin(let sm):
switch sm {
case .recipientV1:
plugin.runRecipientV1()
case .identityV1:
plugin.runIdentityV1()
}
}
} catch {
print("\(CommandLine.arguments[0]): error: \(error.localizedDescription)")
exit(-1)
}
}
}
/// Command-line options parser
struct Options {
enum Error: LocalizedError, Equatable {
case unknownOption(String)
case missingValue(String)
case invalidValue(String, String)
public var errorDescription: String? {
switch self {
case .unknownOption(let option): return "unknown option: `\(option)`"
case .missingValue(let option): return "missing value for option `\(option)`"
case .invalidValue(let option, let value):
return "invalid value for option `\(option)`: `\(value)`"
}
}
}
enum StateMachine: String {
case recipientV1 = "recipient-v1"
case identityV1 = "identity-v1"
}
enum Command: Equatable {
case help
case version
case keygen
case plugin(StateMachine)
}
var command: Command
var output: String?
enum AccessControl: String {
case none = "none"
case passcode = "passcode"
case anyBiometry = "any-biometry"
case anyBiometryOrPasscode = "any-biometry-or-passcode"
case anyBiometryAndPasscode = "any-biometry-and-passcode"
case currentBiometry = "current-biometry"
case currentBiometryAndPasscode = "current-biometry-and-passcode"
var keyAccessControl: KeyAccessControl {
switch self {
case .none: return KeyAccessControl.none
case .passcode: return KeyAccessControl.passcode
case .anyBiometry: return KeyAccessControl.anyBiometry
case .anyBiometryOrPasscode: return KeyAccessControl.anyBiometryOrPasscode
case .anyBiometryAndPasscode: return KeyAccessControl.anyBiometryAndPasscode
case .currentBiometry: return KeyAccessControl.currentBiometry
case .currentBiometryAndPasscode: return KeyAccessControl.currentBiometryAndPasscode
}
}
}
var accessControl = AccessControl.anyBiometryOrPasscode
static var help =
"""
Usage:
age-plugin-se keygen [-o OUTPUT] [--access-control ACCESS_CONTROL]
Options:
-o, --output OUTPUT Write the result to the file at path OUTPUT
--access-control ACCESS_CONTROL Access control for using the generated key.
When using current biometry, adding or removing a fingerprint stops the
key from working. Removing an added fingerprint enables the key again.
Supported values: none, passcode,
any-biometry, any-biometry-and-passcode, any-biometry-or-passcode,
current-biometry, current-biometry-and-passcode
Default: any-biometry-or-passcode.
Example:
$ age-plugin-se keygen -o key.txt
Public key: age1se1qg8vwwqhztnh3vpt2nf2xwn7famktxlmp0nmkfltp8lkvzp8nafkqleh258
$ tar cvz ~/data | age -r age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
"""
static func parse(_ args: [String]) throws -> Options {
var opts = Options(command: .help)
var i = 1
while i < args.count {
let arg = args[i]
if arg == "keygen" {
opts.command = .keygen
} else if ["--help", "-h"].contains(arg) {
opts.command = .help
break
} else if ["--version"].contains(arg) {
opts.command = .version
break
} else if [
"--age-plugin", "-o", "--output", "--access-control",
].contains(where: {
arg == $0 || arg.hasPrefix($0 + "=")
}) {
let argps = arg.split(separator: "=", maxSplits: 1)
let value: String
if argps.count == 1 {
i += 1
if i >= args.count {
throw Error.missingValue(arg)
}
value = args[i]
} else {
value = String(argps[1])
}
let arg = String(argps[0])
switch arg {
case "--age-plugin":
opts.command = try .plugin(
StateMachine(rawValue: value) ?? { throw Error.invalidValue(arg, value) }())
case "-o", "--output":
opts.output = value
case "--access-control":
opts.accessControl =
try AccessControl(rawValue: value) ?? { throw Error.invalidValue(arg, value) }()
default:
assert(false)
}
} else {
throw Error.unknownOption(arg)
}
i += 1
}
return opts
}
}

78
Sources/Crypto.swift Normal file
View File

@@ -0,0 +1,78 @@
import Foundation
#if !os(Linux) && !os(Windows)
import CryptoKit
import LocalAuthentication
#else
import Crypto
struct SecAccessControl {}
#endif
/// Abstraction for random/unpredictable/system-specific crypto operations
protocol Crypto {
var isSecureEnclaveAvailable: Bool { get }
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws -> SecureEnclavePrivateKey
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey
}
protocol SecureEnclavePrivateKey {
var publicKey: P256.KeyAgreement.PublicKey { get }
var dataRepresentation: Data { get }
func sharedSecretFromKeyAgreement(with publicKeyShare: P256.KeyAgreement.PublicKey) throws
-> SharedSecret
}
#if !os(Linux) && !os(Windows)
class CryptoKitCrypto: Crypto {
let context = LAContext()
var isSecureEnclaveAvailable: Bool {
return SecureEnclave.isAvailable
}
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey {
return try SecureEnclave.P256.KeyAgreement.PrivateKey(
dataRepresentation: dataRepresentation, authenticationContext: context)
}
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws
-> SecureEnclavePrivateKey
{
return try SecureEnclave.P256.KeyAgreement.PrivateKey(
accessControl: accessControl, authenticationContext: context)
}
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey {
return P256.KeyAgreement.PrivateKey()
}
}
extension SecureEnclave.P256.KeyAgreement.PrivateKey: SecureEnclavePrivateKey {
}
#else
class CryptoKitCrypto: Crypto {
var isSecureEnclaveAvailable: Bool {
return false
}
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey {
throw Plugin.Error.seUnsupported
}
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws
-> SecureEnclavePrivateKey
{
throw Plugin.Error.seUnsupported
}
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey {
return P256.KeyAgreement.PrivateKey()
}
}
#endif

414
Sources/Plugin.swift Normal file
View File

@@ -0,0 +1,414 @@
import Foundation
#if !os(Linux) && !os(Windows)
import CryptoKit
#else
import Crypto
#endif
class Plugin {
var crypto: Crypto
var stream: Stream
init(crypto: Crypto, stream: Stream) {
self.crypto = crypto
self.stream = stream
}
func generateKey(accessControl: KeyAccessControl, now: Date) throws -> (String, String) {
if !crypto.isSecureEnclaveAvailable {
throw Error.seUnsupported
}
#if !os(Linux) && !os(Windows)
let createdAt = now.ISO8601Format()
var accessControlFlags: SecAccessControlCreateFlags = [.privateKeyUsage]
if accessControl == .anyBiometry || accessControl == .anyBiometryAndPasscode {
accessControlFlags.insert(.biometryAny)
}
if accessControl == .currentBiometry || accessControl == .currentBiometryAndPasscode {
accessControlFlags.insert(.biometryCurrentSet)
}
if accessControl == .passcode || accessControl == .anyBiometryAndPasscode
|| accessControl == .currentBiometryAndPasscode
{
accessControlFlags.insert(.devicePasscode)
}
if accessControl == .anyBiometryOrPasscode {
accessControlFlags.insert(.userPresence)
}
var error: Unmanaged<CFError>?
guard
let secAccessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
accessControlFlags,
&error)
else {
throw error!.takeRetainedValue() as Swift.Error
}
#else
// FIXME: ISO8601Format currently not supported on Linux:
// https://github.com/apple/swift-corelibs-foundation/issues/4618
// This code is only reached in unit tests on Linux anyway
let createdAt = "1997-02-02T02:26:51Z"
let secAccessControl = SecAccessControl()
#endif
let privateKey = try crypto.newSecureEnclavePrivateKey(accessControl: secAccessControl)
let recipient = privateKey.publicKey.ageRecipient
let identity = privateKey.ageIdentity
let accessControlStr: String
switch accessControl {
case .none: accessControlStr = "none"
case .passcode: accessControlStr = "passcode"
case .anyBiometry: accessControlStr = "any biometry"
case .anyBiometryOrPasscode: accessControlStr = "any biometry or passcode"
case .anyBiometryAndPasscode: accessControlStr = "any biometry and passcode"
case .currentBiometry: accessControlStr = "current biometry"
case .currentBiometryAndPasscode: accessControlStr = "current biometry and passcode"
}
let contents = """
# created: \(createdAt)
# access control: \(accessControlStr)
# public key: \(recipient)
\(identity)
"""
return (contents, recipient)
}
func runRecipientV1() {
var recipients: [String] = []
var identities: [String] = []
var fileKeys: [Data] = []
// Phase 1
loop: while true {
let stanza = try! Stanza.readFrom(stream: stream)
switch stanza.type {
case "add-recipient":
recipients.append(stanza.args[0])
case "add-identity":
identities.append(stanza.args[0])
case "wrap-file-key":
fileKeys.append(stanza.body)
case "done":
break loop
default:
continue
}
}
// Phase 2
var stanzas: [Stanza] = []
var errors: [Stanza] = []
var recipientKeys: [P256.KeyAgreement.PublicKey] = []
recipients.enumerated().forEach { (index, recipient) in
do {
recipientKeys.append(try P256.KeyAgreement.PublicKey(ageRecipient: recipient))
} catch {
errors.append(
Stanza(error: "recipient", args: [String(index)], message: error.localizedDescription))
}
}
identities.enumerated().forEach { (index, identity) in
do {
recipientKeys.append(
(try newSecureEnclavePrivateKey(ageIdentity: identity, crypto: crypto)).publicKey)
} catch {
errors.append(
Stanza(error: "identity", args: [String(index)], message: error.localizedDescription))
}
}
fileKeys.enumerated().forEach { (index, fileKey) in
for recipientKey in recipientKeys {
do {
let ephemeralSecretKey = self.crypto.newEphemeralPrivateKey()
let ephemeralPublicKeyBytes = ephemeralSecretKey.publicKey.compressedRepresentation
// CryptoKit PublicKeys can be the identity point by construction (see CryptoTests), but
// these keys can't be used in any operation. This is undocumented, but a documentation request
// has been filed as FB11989432.
// Swift Crypto PublicKeys cannot be the identity point by construction.
// Compresed representation cannot be the identity point anyway (?)
// Therefore, the shared secret cannot be all 0x00 bytes, so we don't need
// to explicitly check this here.
let sharedSecret = try ephemeralSecretKey.sharedSecretFromKeyAgreement(with: recipientKey)
let salt = ephemeralPublicKeyBytes + recipientKey.compressedRepresentation
let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self, salt: salt,
sharedInfo: "piv-p256".data(using: .utf8)!,
outputByteCount: 32
)
let sealedBox = try ChaChaPoly.seal(
fileKey, using: wrapKey, nonce: try! ChaChaPoly.Nonce(data: Data(count: 12)))
stanzas.append(
Stanza(
type: "recipient-stanza",
args: [
String(index),
"piv-p256",
recipientKey.tag.base64RawEncodedString,
ephemeralPublicKeyBytes.base64RawEncodedString,
], body: sealedBox.ciphertext + sealedBox.tag
))
} catch {
errors.append(
Stanza(error: "internal", args: [], message: error.localizedDescription))
}
}
}
for stanza in (errors.isEmpty ? stanzas : errors) {
stanza.writeTo(stream: stream)
let resp = try! Stanza.readFrom(stream: stream)
assert(resp.type == "ok")
}
Stanza(type: "done").writeTo(stream: stream)
}
func runIdentityV1() {
// Phase 1
var identities: [String] = []
var recipientStanzas: [Stanza] = []
loop: while true {
let stanza = try! Stanza.readFrom(stream: stream)
switch stanza.type {
case "add-identity":
identities.append(stanza.args[0])
case "recipient-stanza":
recipientStanzas.append(stanza)
case "done":
break loop
default:
continue
}
}
// Phase 2
var identityKeys: [SecureEnclavePrivateKey] = []
var errors: [Stanza] = []
// Construct identities
identities.enumerated().forEach { (index, identity) in
do {
identityKeys.append(
(try newSecureEnclavePrivateKey(ageIdentity: identity, crypto: crypto)))
} catch {
errors.append(
Stanza(error: "identity", args: [String(index)], message: error.localizedDescription))
}
}
var fileResponses: [Int: Stanza] = [:]
if errors.isEmpty {
// Check structural validity
recipientStanzas.enumerated().forEach { (index, recipientStanza) in
let fileIndex = Int(recipientStanza.args[0])!
switch recipientStanza.args[1] {
case "piv-p256":
if recipientStanza.args.count != 4 {
fileResponses[fileIndex] = Stanza(
error: "stanza", args: [String(fileIndex)], message: "incorrect argument count")
return
}
let tag = Data(base64RawEncoded: recipientStanza.args[2])
if tag == nil || tag!.count != 4 {
fileResponses[fileIndex] = Stanza(
error: "stanza", args: [String(fileIndex)], message: "invalid tag")
return
}
let share = Data(base64RawEncoded: recipientStanza.args[3])
if share == nil || share!.count != 33 {
fileResponses[fileIndex] = Stanza(
error: "stanza", args: [String(fileIndex)], message: "invalid share")
return
}
if recipientStanza.body.count != 32 {
fileResponses[fileIndex] = Stanza(
error: "stanza", args: [String(fileIndex)],
message: "invalid body")
return
}
default:
return
}
}
// Unwrap keys
recipientStanzas.enumerated().forEach { (index, recipientStanza) in
let fileIndex = Int(recipientStanza.args[0])!
if fileResponses[fileIndex] != nil {
return
}
let type = recipientStanza.args[1]
if type != "piv-p256" {
return
}
let tag = recipientStanza.args[2]
let share = recipientStanza.args[3]
for identity in identityKeys {
if identity.publicKey.tag.base64RawEncodedString != tag {
continue
}
do {
let shareKeyData = Data(base64RawEncoded: share)!
let shareKey: P256.KeyAgreement.PublicKey = try P256.KeyAgreement.PublicKey(
compressedRepresentation: shareKeyData)
// CryptoKit PublicKeys can be the identity point by construction (see CryptoTests), but
// these keys can't be used in any operation. This is undocumented, but a documentation request
// has been filed as FB11989432.
// Swift Crypto PublicKeys cannot be the identity point by construction.
// Compresed representation cannot be the identity point anyway (?)
// Therefore, the shared secret cannot be all 0x00 bytes, so we don't need
// to explicitly check this here.
let sharedSecret: SharedSecret = try identity.sharedSecretFromKeyAgreement(
with: shareKey)
let salt =
shareKey.compressedRepresentation + identity.publicKey.compressedRepresentation
let wrapKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self, salt: salt,
sharedInfo: "piv-p256".data(using: .utf8)!,
outputByteCount: 32
)
let unwrappedKey = try ChaChaPoly.open(
ChaChaPoly.SealedBox(
combined: try! ChaChaPoly.Nonce(data: Data(count: 12)) + recipientStanza.body),
using: wrapKey)
fileResponses[fileIndex] = Stanza(
type: "file-key",
args: [String(fileIndex)],
body: unwrappedKey
)
} catch {
Stanza(type: "msg", body: error.localizedDescription.data(using: .utf8)!).writeTo(
stream: stream)
let resp = try! Stanza.readFrom(stream: self.stream)
assert(resp.type == "ok")
// continue
}
}
}
}
let responses = fileResponses.keys.sorted().map({ k in fileResponses[k]! })
for stanza in (errors.isEmpty ? responses : errors) {
stanza.writeTo(stream: stream)
let resp = try! Stanza.readFrom(stream: stream)
assert(resp.type == "ok")
}
Stanza(type: "done").writeTo(stream: stream)
}
enum Error: LocalizedError, Equatable {
case seUnsupported
case incompleteStanza
case invalidStanza
case unknownHRP(String)
public var errorDescription: String? {
switch self {
case .seUnsupported: return "Secure Enclave not supported on this device"
case .incompleteStanza: return "incomplete stanza"
case .invalidStanza: return "invalid stanza"
case .unknownHRP(let hrp): return "unknown HRP: \(hrp)"
}
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////
struct Stanza: Equatable {
var type: String
var args: [String] = []
var body = Data()
static func readFrom(stream: Stream) throws -> Stanza {
guard let header = stream.readLine() else {
throw Plugin.Error.incompleteStanza
}
let headerParts = header.components(separatedBy: " ")
if headerParts.count < 2 {
throw Plugin.Error.invalidStanza
}
if headerParts[0] != "->" {
throw Plugin.Error.invalidStanza
}
var body = Data()
while true {
guard let line = stream.readLine() else {
throw Plugin.Error.incompleteStanza
}
guard let lineData = Data(base64RawEncoded: line) else {
throw Plugin.Error.invalidStanza
}
if lineData.count > 48 {
throw Plugin.Error.invalidStanza
}
body.append(lineData)
if lineData.count < 48 {
break
}
}
return Stanza(type: headerParts[1], args: Array(headerParts[2...]), body: body)
}
func writeTo(stream: Stream) {
let parts = ([type] + args).joined(separator: " ")
stream.writeLine("-> \(parts)\n\(body.base64RawEncodedString)")
}
}
extension Stanza {
init(error type: String, args: [String] = [], message: String) {
self.type = "error"
self.args = [type] + args
self.body = message.data(using: .utf8)!
}
}
enum KeyAccessControl {
case none
case passcode
case anyBiometry
case anyBiometryOrPasscode
case anyBiometryAndPasscode
case currentBiometry
case currentBiometryAndPasscode
}
extension P256.KeyAgreement.PublicKey {
init(ageRecipient: String) throws {
let id = try Bech32().decode(ageRecipient)
if id.hrp != "age1se" {
throw Plugin.Error.unknownHRP(id.hrp)
}
self = try P256.KeyAgreement.PublicKey(compressedRepresentation: id.data)
}
var tag: Data {
return Data(SHA256.hash(data: compressedRepresentation).prefix(4))
}
var ageRecipient: String {
return Bech32().encode(hrp: "age1se", data: self.compressedRepresentation)
}
}
extension SecureEnclavePrivateKey {
var ageIdentity: String {
return Bech32().encode(
hrp: "AGE-PLUGIN-SE-",
data: self.dataRepresentation)
}
}
func newSecureEnclavePrivateKey(ageIdentity: String, crypto: Crypto) throws
-> SecureEnclavePrivateKey
{
let id = try Bech32().decode(ageIdentity)
if id.hrp != "AGE-PLUGIN-SE-" {
throw Plugin.Error.unknownHRP(id.hrp)
}
return try crypto.newSecureEnclavePrivateKey(dataRepresentation: id.data)
}

19
Sources/Stream.swift Normal file
View File

@@ -0,0 +1,19 @@
import Foundation
/// Abstraction of a line-based communication stream
protocol Stream {
func readLine() -> String?
func writeLine(_: String)
}
class StandardIOStream: Stream {
func readLine() -> String? {
return Swift.readLine(strippingNewline: true)
}
func writeLine(_ line: String) {
FileHandle.standardOutput.write(line.data(using: .utf8)!)
FileHandle.standardOutput.write(Data([0xa]))
fflush(stdout)
}
}

79
Tests/Base64Tests.swift Normal file
View File

@@ -0,0 +1,79 @@
import XCTest
@testable import age_plugin_se
final class Base64Tests: XCTestCase {
func testDataInitBase64RawEncoded_NeedsNoPad() throws {
XCTAssertEqual(
Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]),
Data(base64RawEncoded: "AQIDBAUG"))
}
func testDataInitBase64RawEncoded_Needs1Pad() throws {
XCTAssertEqual(
Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]),
Data(base64RawEncoded: "AQIDBAUGBwg"))
}
func testDataInitBase64RawEncoded_Needs2Pads() throws {
XCTAssertEqual(
Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]),
Data(base64RawEncoded: "AQIDBAUGBw"))
}
func testDataInitBase64RawEncoded_HasPad() throws {
XCTAssertEqual(
nil,
Data(base64RawEncoded: "AQIDBAUGBwg="))
}
func testDataInit_InvalidBase64() throws {
XCTAssertEqual(
nil,
Data(base64RawEncoded: "A_QIDBAUG"))
}
func testDataBase64RawEncodedData() throws {
XCTAssertEqual(
"AQIDBAUGBw".data(using: .utf8),
Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]).base64RawEncodedData)
}
func testDataBase64RawEncodedData_Long() throws {
XCTAssertEqual(
"""
TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
bmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFi
b3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVu
aWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uIHVsbGFtY28gbGFib3JpcyBu
aXNpIHV0IGFsaXF1aXAgZXggZWEgY29tbW9kbyBjb25zZXF1YXQuIER1aXMgYXV0
ZSBpcnVyZSBkb2xvciBpbiByZXByZWhlbmRlcml0IGluIHZvbHVwdGF0ZSB2ZWxp
dCBlc3NlIGNpbGx1bSBkb2xvcmUgZXUgZnVnaWF0IG51bGxhIHBhcmlhdHVyLiBF
eGNlcHRldXIgc2ludCBvY2NhZWNhdCBjdXBpZGF0YXQgbm9uIHByb2lkZW50LCBz
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlk
IGVzdCBsYWJvcnVtLg
""".data(using: .utf8),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
.data(using: .utf8)!
.base64RawEncodedData)
}
func testDataBase64RawEncodedString_Long() throws {
XCTAssertEqual(
"""
TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
bmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFi
b3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVu
aWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uIHVsbGFtY28gbGFib3JpcyBu
aXNpIHV0IGFsaXF1aXAgZXggZWEgY29tbW9kbyBjb25zZXF1YXQuIER1aXMgYXV0
ZSBpcnVyZSBkb2xvciBpbiByZXByZWhlbmRlcml0IGluIHZvbHVwdGF0ZSB2ZWxp
dCBlc3NlIGNpbGx1bSBkb2xvcmUgZXUgZnVnaWF0IG51bGxhIHBhcmlhdHVyLiBF
eGNlcHRldXIgc2ludCBvY2NhZWNhdCBjdXBpZGF0YXQgbm9uIHByb2lkZW50LCBz
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlk
IGVzdCBsYWJvcnVtLg
""",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
.data(using: .utf8)!
.base64RawEncodedString)
}
}

103
Tests/Bech32Tests.swift Normal file
View File

@@ -0,0 +1,103 @@
import XCTest
@testable import age_plugin_se
final class Bech32Tests: XCTestCase {
func testEncode() throws {
XCTAssertEqual(
"age1se1qv9p3zge0tmqxczme5pn3p7g3x80t0uxvlk30s9vevjq0lxuy8rzss09jyq",
Bech32().encode(
hrp: "age1se",
data: Data([
0x03, 0x0a, 0x18, 0x89, 0x19, 0x7a, 0xf6, 0x03, 0x60, 0x5b, 0xcd, 0x03, 0x38, 0x87, 0xc8,
0x89, 0x8e, 0xf5, 0xbf, 0x86, 0x67, 0xed, 0x17, 0xc0, 0xac, 0xcb, 0x24, 0x07, 0xfc, 0xdc,
0x21, 0xc6, 0x28,
])))
}
func testEncode_LongUppercase() throws {
XCTAssertEqual(
"AGE-PLUGIN-SE-1QJPQZ7P3SGQHGVYP75XQYUNTXXQ7UVQTPSPKY6TYQSZDNVLMZYCYSRQRWP6KYPZPQNV20QEQRP3CLMWQALZ8V6TFESK6VDUL30F0D7TC2EXE2RV3Z2TQ5L0ZQFLHJMLY64XS8ESX6KFTL43MN86QVA0W982DTFWL4XMRT7CGXQYQCQMJDDHSYQGQXQRSCQNTWSPQZPPS9CXQYAMTQS5W4LASFLR432Z3658D86JEL8MKGE9XTJLHK4P3ASKWWZ8W6G7RWVLQYSWECSL8XF4RQPCVQF3XXQSPPYCQWRQZDDMQYQGZXQTSCQMTD9JQGYRALY9FHQYLGFET999CZUFGPGLJXQNSCQMJDDKSGGXE56G2EH3Y5V0QPUM8VHADV3FS2TKDR4F2M8266Y444ZFF3FW0VCC85RQZV4JRZAPSWGXQXCTRDSCKKVPFPSPK7CMTXY3RQGQVQD3HQMCVR9ZX2ANFVDJ57AMWV4EYZAT5DPJKUARFVDSHG6T0DCCQJRQYDAJX2MQPQYQNQ2SVQ3HHXEMWXY3RQGQVQD3HQMCVR9ZX2ANFVDJ57AMWV4EYZAT5DPJKUARFVDSHG6T0DCCQWRQZDASSZQGPK9FKDS",
Bech32().encode(
hrp: "AGE-PLUGIN-SE-",
data: Data([
0x04, 0x82, 0x01, 0x78, 0x31, 0x82, 0x01, 0x74, 0x30, 0x81, 0xf5, 0x0c, 0x02, 0x72, 0x6b,
0x31, 0x81, 0xee, 0x30, 0x0b, 0x0c, 0x03, 0x62, 0x69, 0x64, 0x04, 0x04, 0xd9, 0xb3, 0xfb,
0x11, 0x30, 0x48, 0x0c, 0x03, 0x70, 0x75, 0x62, 0x04, 0x41, 0x04, 0xd8, 0xa7, 0x83, 0x20,
0x18, 0x63, 0x8f, 0xed, 0xc0, 0xef, 0xc4, 0x76, 0x69, 0x69, 0xcc, 0x2d, 0xa6, 0x37, 0x9f,
0x8b, 0xd2, 0xf6, 0xf9, 0x78, 0x56, 0x4d, 0x95, 0x0d, 0x91, 0x12, 0x96, 0x0a, 0x7d, 0xe2,
0x02, 0x7f, 0x79, 0x6f, 0xe4, 0xd5, 0x4d, 0x03, 0xe6, 0x06, 0xd5, 0x92, 0xbf, 0xd6, 0x3b,
0x99, 0xf4, 0x06, 0x75, 0xee, 0x29, 0xd4, 0xd5, 0xa5, 0xdf, 0xa9, 0xb6, 0x35, 0xfb, 0x08,
0x30, 0x08, 0x0c, 0x03, 0x72, 0x6b, 0x6f, 0x02, 0x01, 0x00, 0x30, 0x07, 0x0c, 0x02, 0x6b,
0x74, 0x02, 0x01, 0x04, 0x30, 0x2e, 0x0c, 0x02, 0x77, 0x6b, 0x04, 0x28, 0xea, 0xff, 0xb0,
0x4f, 0xc7, 0x58, 0xa8, 0x51, 0xd5, 0x0e, 0xd3, 0xea, 0x59, 0xf9, 0xf7, 0x64, 0x64, 0xa6,
0x5c, 0xbf, 0x7b, 0x54, 0x31, 0xec, 0x2c, 0xe7, 0x08, 0xee, 0xd2, 0x3c, 0x37, 0x33, 0xe0,
0x24, 0x1d, 0x9c, 0x43, 0xe7, 0x32, 0x6a, 0x30, 0x07, 0x0c, 0x02, 0x62, 0x63, 0x02, 0x01,
0x09, 0x30, 0x07, 0x0c, 0x02, 0x6b, 0x76, 0x02, 0x01, 0x02, 0x30, 0x17, 0x0c, 0x03, 0x6b,
0x69, 0x64, 0x04, 0x10, 0x7d, 0xf9, 0x0a, 0x9b, 0x80, 0x9f, 0x42, 0x72, 0xb2, 0x94, 0xb8,
0x17, 0x12, 0x80, 0xa3, 0xf2, 0x30, 0x27, 0x0c, 0x03, 0x72, 0x6b, 0x6d, 0x04, 0x20, 0xd9,
0xa6, 0x90, 0xac, 0xde, 0x24, 0xa3, 0x1e, 0x00, 0xf3, 0x67, 0x65, 0xfa, 0xd6, 0x45, 0x30,
0x52, 0xec, 0xd1, 0xd5, 0x2a, 0xd9, 0xd5, 0xad, 0x12, 0xb5, 0xa8, 0x92, 0x98, 0xa5, 0xcf,
0x66, 0x30, 0x7a, 0x0c, 0x02, 0x65, 0x64, 0x31, 0x74, 0x30, 0x72, 0x0c, 0x03, 0x61, 0x63,
0x6c, 0x31, 0x6b, 0x30, 0x29, 0x0c, 0x03, 0x6f, 0x63, 0x6b, 0x31, 0x22, 0x30, 0x20, 0x0c,
0x03, 0x63, 0x70, 0x6f, 0x0c, 0x19, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x4f, 0x77, 0x6e,
0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x30, 0x09, 0x0c, 0x04, 0x6f, 0x64, 0x65, 0x6c, 0x01, 0x01, 0x01, 0x30, 0x2a, 0x0c,
0x04, 0x6f, 0x73, 0x67, 0x6e, 0x31, 0x22, 0x30, 0x20, 0x0c, 0x03, 0x63, 0x70, 0x6f, 0x0c,
0x19, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x41, 0x75, 0x74,
0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x30, 0x07, 0x0c, 0x02,
0x6f, 0x61, 0x01, 0x01, 0x01,
])))
}
func testDecode() throws {
let result = try Bech32().decode(
"age1se1qv9p3zge0tmqxczme5pn3p7g3x80t0uxvlk30s9vevjq0lxuy8rzss09jyq")
XCTAssertEqual(
"age1se", result.hrp)
XCTAssertEqual(
Data([
0x03, 0x0a, 0x18, 0x89, 0x19, 0x7a, 0xf6, 0x03, 0x60, 0x5b, 0xcd, 0x03, 0x38, 0x87, 0xc8,
0x89, 0x8e, 0xf5, 0xbf, 0x86, 0x67, 0xed, 0x17, 0xc0, 0xac, 0xcb, 0x24, 0x07, 0xfc, 0xdc,
0x21, 0xc6, 0x28,
]), result.data)
}
func testDecode_LongUppercase() throws {
let result = try Bech32().decode(
"AGE-PLUGIN-SE-1QJPQZ7P3SGQHGVYP75XQYUNTXXQ7UVQTPSPKY6TYQSZDNVLMZYCYSRQRWP6KYPZPQNV20QEQRP3CLMWQALZ8V6TFESK6VDUL30F0D7TC2EXE2RV3Z2TQ5L0ZQFLHJMLY64XS8ESX6KFTL43MN86QVA0W982DTFWL4XMRT7CGXQYQCQMJDDHSYQGQXQRSCQNTWSPQZPPS9CXQYAMTQS5W4LASFLR432Z3658D86JEL8MKGE9XTJLHK4P3ASKWWZ8W6G7RWVLQYSWECSL8XF4RQPCVQF3XXQSPPYCQWRQZDDMQYQGZXQTSCQMTD9JQGYRALY9FHQYLGFET999CZUFGPGLJXQNSCQMJDDKSGGXE56G2EH3Y5V0QPUM8VHADV3FS2TKDR4F2M8266Y444ZFF3FW0VCC85RQZV4JRZAPSWGXQXCTRDSCKKVPFPSPK7CMTXY3RQGQVQD3HQMCVR9ZX2ANFVDJ57AMWV4EYZAT5DPJKUARFVDSHG6T0DCCQJRQYDAJX2MQPQYQNQ2SVQ3HHXEMWXY3RQGQVQD3HQMCVR9ZX2ANFVDJ57AMWV4EYZAT5DPJKUARFVDSHG6T0DCCQWRQZDASSZQGPK9FKDS"
)
XCTAssertEqual(
"AGE-PLUGIN-SE-", result.hrp)
// print(result.data.map { String(format: "0x%02x", $0) }.joined(separator: ", "))
XCTAssertEqual(
Data([
0x04, 0x82, 0x01, 0x78, 0x31, 0x82, 0x01, 0x74, 0x30, 0x81, 0xf5, 0x0c, 0x02, 0x72, 0x6b,
0x31, 0x81, 0xee, 0x30, 0x0b, 0x0c, 0x03, 0x62, 0x69, 0x64, 0x04, 0x04, 0xd9, 0xb3, 0xfb,
0x11, 0x30, 0x48, 0x0c, 0x03, 0x70, 0x75, 0x62, 0x04, 0x41, 0x04, 0xd8, 0xa7, 0x83, 0x20,
0x18, 0x63, 0x8f, 0xed, 0xc0, 0xef, 0xc4, 0x76, 0x69, 0x69, 0xcc, 0x2d, 0xa6, 0x37, 0x9f,
0x8b, 0xd2, 0xf6, 0xf9, 0x78, 0x56, 0x4d, 0x95, 0x0d, 0x91, 0x12, 0x96, 0x0a, 0x7d, 0xe2,
0x02, 0x7f, 0x79, 0x6f, 0xe4, 0xd5, 0x4d, 0x03, 0xe6, 0x06, 0xd5, 0x92, 0xbf, 0xd6, 0x3b,
0x99, 0xf4, 0x06, 0x75, 0xee, 0x29, 0xd4, 0xd5, 0xa5, 0xdf, 0xa9, 0xb6, 0x35, 0xfb, 0x08,
0x30, 0x08, 0x0c, 0x03, 0x72, 0x6b, 0x6f, 0x02, 0x01, 0x00, 0x30, 0x07, 0x0c, 0x02, 0x6b,
0x74, 0x02, 0x01, 0x04, 0x30, 0x2e, 0x0c, 0x02, 0x77, 0x6b, 0x04, 0x28, 0xea, 0xff, 0xb0,
0x4f, 0xc7, 0x58, 0xa8, 0x51, 0xd5, 0x0e, 0xd3, 0xea, 0x59, 0xf9, 0xf7, 0x64, 0x64, 0xa6,
0x5c, 0xbf, 0x7b, 0x54, 0x31, 0xec, 0x2c, 0xe7, 0x08, 0xee, 0xd2, 0x3c, 0x37, 0x33, 0xe0,
0x24, 0x1d, 0x9c, 0x43, 0xe7, 0x32, 0x6a, 0x30, 0x07, 0x0c, 0x02, 0x62, 0x63, 0x02, 0x01,
0x09, 0x30, 0x07, 0x0c, 0x02, 0x6b, 0x76, 0x02, 0x01, 0x02, 0x30, 0x17, 0x0c, 0x03, 0x6b,
0x69, 0x64, 0x04, 0x10, 0x7d, 0xf9, 0x0a, 0x9b, 0x80, 0x9f, 0x42, 0x72, 0xb2, 0x94, 0xb8,
0x17, 0x12, 0x80, 0xa3, 0xf2, 0x30, 0x27, 0x0c, 0x03, 0x72, 0x6b, 0x6d, 0x04, 0x20, 0xd9,
0xa6, 0x90, 0xac, 0xde, 0x24, 0xa3, 0x1e, 0x00, 0xf3, 0x67, 0x65, 0xfa, 0xd6, 0x45, 0x30,
0x52, 0xec, 0xd1, 0xd5, 0x2a, 0xd9, 0xd5, 0xad, 0x12, 0xb5, 0xa8, 0x92, 0x98, 0xa5, 0xcf,
0x66, 0x30, 0x7a, 0x0c, 0x02, 0x65, 0x64, 0x31, 0x74, 0x30, 0x72, 0x0c, 0x03, 0x61, 0x63,
0x6c, 0x31, 0x6b, 0x30, 0x29, 0x0c, 0x03, 0x6f, 0x63, 0x6b, 0x31, 0x22, 0x30, 0x20, 0x0c,
0x03, 0x63, 0x70, 0x6f, 0x0c, 0x19, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x4f, 0x77, 0x6e,
0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x30, 0x09, 0x0c, 0x04, 0x6f, 0x64, 0x65, 0x6c, 0x01, 0x01, 0x01, 0x30, 0x2a, 0x0c,
0x04, 0x6f, 0x73, 0x67, 0x6e, 0x31, 0x22, 0x30, 0x20, 0x0c, 0x03, 0x63, 0x70, 0x6f, 0x0c,
0x19, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x41, 0x75, 0x74,
0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x30, 0x07, 0x0c, 0x02,
0x6f, 0x61, 0x01, 0x01, 0x01,
]), result.data)
}
}

49
Tests/CLITests.swift Normal file
View File

@@ -0,0 +1,49 @@
import XCTest
@testable import age_plugin_se
final class OptionsTests: XCTestCase {
func testParse_NoArguments() throws {
let options = try Options.parse(["_"])
XCTAssertEqual(.help, options.command)
}
func testParse_CommandWithHelp() throws {
let options = try Options.parse(["_", "keygen", "--help"])
XCTAssertEqual(.help, options.command)
}
func testParse_CommandWithVersion() throws {
let options = try Options.parse(["_", "keygen", "--version"])
XCTAssertEqual(.version, options.command)
}
func testParse_Keygen() throws {
let options = try Options.parse(["_", "keygen", "--access-control=any-biometry"])
XCTAssertEqual(.keygen, options.command)
XCTAssertEqual(.anyBiometry, options.accessControl)
}
func testParse_AgePlugin() throws {
let options = try Options.parse(["_", "keygen", "--age-plugin=identity-v1"])
XCTAssertEqual(.plugin(.identityV1), options.command)
}
func testParse_LongOptionWithEqual() throws {
let options = try Options.parse(["_", "keygen", "--output=foo.txt"])
XCTAssertEqual(.keygen, options.command)
XCTAssertEqual("foo.txt", options.output)
}
func testParse_LongOptionWithoutEqual() throws {
let options = try Options.parse(["_", "keygen", "--output", "foo.txt"])
XCTAssertEqual(.keygen, options.command)
XCTAssertEqual("foo.txt", options.output)
}
func testParse_LongOptionWithoutValue() throws {
XCTAssertThrowsError(try Options.parse(["_", "keygen", "--output"])) { error in
XCTAssertEqual(Options.Error.missingValue("--output"), error as! Options.Error)
}
}
}

71
Tests/CryptoTests.swift Normal file
View File

@@ -0,0 +1,71 @@
import XCTest
@testable import age_plugin_se
#if !os(Linux) && !os(Windows)
import CryptoKit
#else
import Crypto
#endif
final class CryptoKitCryptoTests: XCTestCase {
var crypto = CryptoKitCrypto()
func testNewEphemeralPrivateKey() throws {
let k1 = crypto.newEphemeralPrivateKey()
let k2 = crypto.newEphemeralPrivateKey()
XCTAssertNotEqual(k1.rawRepresentation, k2.rawRepresentation)
XCTAssertNotEqual(k1.publicKey.rawRepresentation, k2.publicKey.rawRepresentation)
}
func testNewEphemeralPrivateKey_DifferentCrypto() throws {
let k1 = CryptoKitCrypto().newEphemeralPrivateKey()
let k2 = CryptoKitCrypto().newEphemeralPrivateKey()
XCTAssertNotEqual(k1.rawRepresentation, k2.rawRepresentation)
XCTAssertNotEqual(k1.publicKey.rawRepresentation, k2.publicKey.rawRepresentation)
}
// A test to validate that CryptoKit / Swift Crypto cannot do any operations with points at infinity
func testPointAtInfinity() throws {
let sk = P256.KeyAgreement.PrivateKey()
// base64.b64encode(ECC.generate(curve="p256").export_key(format="DER"))
let pk = try P256.KeyAgreement.PublicKey(derRepresentation: Data(base64Encoded: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0Zl262mVCr+1pi9396tEdXC0HIQnENUkWal3nOzLWvX+TYja1xVE++6WzRvunrkBT91380BIJZvB7ZiiEN+Y1A==")!)
// Test that operations work from a regular DER constructed key
let _ = try sk.sharedSecretFromKeyAgreement(with: pk)
func run() throws {
// base64.b64encode(ECC.EccKey(curve = "p256", point = ECC.generate(curve="p256").pointQ.point_at_infinity()).export_key(format="DER"))
// Swift Crypto throws at construction time
let identityPK = try P256.KeyAgreement.PublicKey(derRepresentation: Data(base64Encoded: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==")!)
// CryptoKit throws at operation time
let _ = try sk.sharedSecretFromKeyAgreement(with: identityPK)
}
XCTAssertThrowsError(try run())
}
}
final class DummyCryptoTests: XCTestCase {
var crypto = DummyCrypto()
func testNewEphemeralPrivateKey() throws {
let k1 = crypto.newEphemeralPrivateKey()
let k2 = crypto.newEphemeralPrivateKey()
XCTAssertNotEqual(k1.rawRepresentation, k2.rawRepresentation)
XCTAssertNotEqual(k1.publicKey.rawRepresentation, k2.publicKey.rawRepresentation)
}
func testNewEphemeralPrivateKey_DifferentCrypto() throws {
let k1 = DummyCrypto().newEphemeralPrivateKey()
let k2 = DummyCrypto().newEphemeralPrivateKey()
XCTAssertEqual(k1.rawRepresentation, k2.rawRepresentation)
XCTAssertEqual(k1.publicKey.rawRepresentation, k2.publicKey.rawRepresentation)
}
}

75
Tests/DummyCrypto.swift Normal file
View File

@@ -0,0 +1,75 @@
import Foundation
@testable import age_plugin_se
#if !os(Linux) && !os(Windows)
import CryptoKit
#else
import Crypto
#endif
class DummyCrypto: Crypto {
// If more keys are needed, add them to the front
var dummyKeys = [
"t8Y0uUHLtBvCtuUz0Hdw2lqbwZf6TgYzYKFWMEEFSs8",
"HxEmObcQ6bcAUC8w6kPWrnlUIwBQoi66ZNpQZ0cAXww",
"dCDteyAKpkwYd8jCunOz0mvWmy+24zvWV41YBD+Pkeg",
"NkkLXSZ+yhx9imKKw9cOsbey4C1XZAPuSDMCgTLENrY",
"bQrp04tXb+diJ6x28Kd8EDt9sCmI5diS36Zy3n49DHg",
"m8/qMMkYDelvL+ihdUFYyKXBn+7We21fZ5zH/I61y3M",
"lQq/Pq0GA2QFGTEiNMQIxZHzBnt+nPRXK5gL3X6nnJY",
"VoUn+n/vzkuDzWgMV9n3e1L+tTSIl0Sg7lXSNDR5XqY",
"3naom0zZxBZcSZCfoNzyjLVmG6hyRKX8bCU3wukusFI",
"N2WRutxd1Ed0l4piqArI2gKYSTG7peE8BYBrLLV7YjQ",
].map { Data(base64RawEncoded: $0)! }
var isSecureEnclaveAvailable = true
var failingOperations = false
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey {
return DummySecureEnclavePrivateKey(
key: try P256.KeyAgreement.PrivateKey(rawRepresentation: dataRepresentation), crypto: self)
}
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws -> SecureEnclavePrivateKey
{
return DummySecureEnclavePrivateKey(
key: try P256.KeyAgreement.PrivateKey(rawRepresentation: dummyKeys.popLast()!), crypto: self)
}
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey {
return try! P256.KeyAgreement.PrivateKey(rawRepresentation: dummyKeys.popLast()!)
}
}
struct DummySecureEnclavePrivateKey: SecureEnclavePrivateKey {
var key: P256.KeyAgreement.PrivateKey
var crypto: DummyCrypto
var publicKey: P256.KeyAgreement.PublicKey {
return key.publicKey
}
var dataRepresentation: Data {
return key.rawRepresentation
}
func sharedSecretFromKeyAgreement(with publicKeyShare: P256.KeyAgreement.PublicKey) throws
-> SharedSecret
{
if crypto.failingOperations {
throw DummyCryptoError.dummyError
}
return try key.sharedSecretFromKeyAgreement(with: publicKeyShare)
}
}
enum DummyCryptoError: LocalizedError {
case dummyError
public var errorDescription: String? {
switch self {
case .dummyError: return "dummy error"
}
}
}

27
Tests/MemoryStream.swift Normal file
View File

@@ -0,0 +1,27 @@
@testable import age_plugin_se
class MemoryStream: Stream {
var inputLines: [String] = []
var outputLines: [String] = []
var output: String {
return outputLines.joined(separator: "\n")
}
func add(input: String) {
inputLines.append(contentsOf: input.components(separatedBy: "\n"))
}
func readLine() -> String? {
if inputLines.isEmpty {
return nil
}
let result = inputLines[0]
inputLines.removeFirst()
return result
}
func writeLine(_ line: String) {
outputLines.append(contentsOf: line.components(separatedBy: "\n"))
}
}

828
Tests/PluginTests.swift Normal file
View File

@@ -0,0 +1,828 @@
import XCTest
@testable import age_plugin_se
#if !os(Linux) && !os(Windows)
import CryptoKit
#else
import Crypto
#endif
final class PluginTests: XCTestCase {
func testCertificateTag() throws {
let key = try P256.KeyAgreement.PublicKey(compactRepresentation: Data(count: 32))
XCTAssertEqual("Ujulpw", key.tag.base64RawEncodedString)
}
// Test to ensure that age-plugin-yubikey has the same output tag
// These values were extracted from a yubikey recipient
func testCertificateTag_YubiKeyPlugin() throws {
let key = try P256.KeyAgreement.PublicKey(
compactRepresentation: Data([
182, 32, 36, 98, 119, 204, 123, 231, 20, 203, 102, 119, 81, 232, 194, 196, 140, 194, 55,
12, 222, 162, 205, 252, 47, 114, 187, 157, 117, 151, 57, 158,
]))
XCTAssertEqual(Data([128, 103, 102, 255]), key.tag)
XCTAssertEqual("gGdm/w", key.tag.base64RawEncodedString)
}
}
final class GenerateTests: XCTestCase {
var stream = MemoryStream()
var crypto = DummyCrypto()
override func setUp() {
stream = MemoryStream()
crypto = DummyCrypto()
}
func testGenerate() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
let result = try plugin.generateKey(
accessControl: .anyBiometryOrPasscode, now: Date(timeIntervalSinceReferenceDate: -123456789.0)
)
XCTAssertEqual(
"""
# created: 1997-02-02T02:26:51Z
# access control: any biometry or passcode
# public key: age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4
AGE-PLUGIN-SE-1XAJERWKUTH2YWAYH3F32SZKGMGPFSJF3HWJ7Z0Q9SP4JEDTMVG6Q6JD2VG
""", result.0)
XCTAssertEqual(
"age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4", result.1)
}
func testGenerate_AnyBiometryAndPasscode() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
let result = try plugin.generateKey(
accessControl: .anyBiometryAndPasscode,
now: Date(timeIntervalSinceReferenceDate: -123456789.0))
XCTAssertEqual(
"""
# created: 1997-02-02T02:26:51Z
# access control: any biometry and passcode
# public key: age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4
AGE-PLUGIN-SE-1XAJERWKUTH2YWAYH3F32SZKGMGPFSJF3HWJ7Z0Q9SP4JEDTMVG6Q6JD2VG
""", result.0)
XCTAssertEqual(
"age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4", result.1)
}
func testGenerate_CurrentBiometry() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
let result = try plugin.generateKey(
accessControl: .currentBiometry, now: Date(timeIntervalSinceReferenceDate: -123456789.0))
XCTAssertEqual(
"""
# created: 1997-02-02T02:26:51Z
# access control: current biometry
# public key: age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4
AGE-PLUGIN-SE-1XAJERWKUTH2YWAYH3F32SZKGMGPFSJF3HWJ7Z0Q9SP4JEDTMVG6Q6JD2VG
""", result.0)
XCTAssertEqual(
"age1se1qvlvs7x2g83gtaqg0dlstnm3ee8tr49dhtdnxudpfd0sy2gedw20kjmseq4", result.1)
}
func testGenerate_NoSecureEnclaveSupport() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
crypto.isSecureEnclaveAvailable = false
XCTAssertThrowsError(
try plugin.generateKey(
accessControl: .anyBiometryOrPasscode,
now: Date(timeIntervalSinceReferenceDate: -123456789.0))
) { error in
XCTAssertEqual(Plugin.Error.seUnsupported, error as! Plugin.Error)
}
}
}
final class RecipientV1Tests: XCTestCase {
var stream = MemoryStream()
var crypto = DummyCrypto()
override func setUp() {
stream = MemoryStream()
crypto = DummyCrypto()
}
// Just a test to get the identities of the test keys used in this test
func testKeys() throws {
let key1 = try! crypto.newSecureEnclavePrivateKey(
dataRepresentation: Data(base64RawEncoded: "OSe+zDK18qF0UrjxYVkmwvxyEdxZHp9F69rElj8bKS8")!)
XCTAssertEqual(
"AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG",
key1.ageIdentity)
XCTAssertEqual(
"age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l",
key1.publicKey.ageRecipient)
let key2 = try! crypto.newSecureEnclavePrivateKey(
dataRepresentation: Data(base64RawEncoded: "kBuQrPyfvCqBXJ5G4YBkqNER201niIeOmlXsRS2gxN0")!)
XCTAssertEqual(
"AGE-PLUGIN-SE-1JQDEPT8UN77Z4Q2UNERWRQRY4RG3RK6DV7YG0R562HKY2TDQCNWSREKAW7",
key2.ageIdentity)
XCTAssertEqual(
"age1se1q0mm28s88km3d8fvwve26xg4tt26cqamhxm79g9xvmw0f2erawj752upj6l",
key2.publicKey.ageRecipient)
}
func testNothing() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(input: "-> done\n")
plugin.runRecipientV1()
XCTAssertEqual("-> done\n", stream.output)
}
func testRecipient() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
""", stream.output)
}
func testIdentity() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
""", stream.output)
}
func testMultipleRecipients() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> add-recipient age1se1q0mm28s88km3d8fvwve26xg4tt26cqamhxm79g9xvmw0f2erawj752upj6l
-> done
-> ok
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> done
""", stream.output)
}
func testMultipleRecipientsMultipleKeys() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAg
-> add-recipient age1se1q0mm28s88km3d8fvwve26xg4tt26cqamhxm79g9xvmw0f2erawj752upj6l
-> done
-> ok
-> ok
-> ok
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> recipient-stanza 1 piv-p256 14yi6A AvEp8Oz0cMnXhpXnWM6cwer4nEDHus/AvNp3kYnUH0Qs
L3ig8s2AqjusH/0lW6ZueSEYhpeV2ofrQpaKP06WI9g
-> recipient-stanza 1 piv-p256 1mgwOA AoIMpSYaKzGl5IBFaM9AFJXmrseGzTzcQjS9R4kRcjRi
vm8flaP+4W08S6LwFENwnEKLlpzZ5YqZ3NdpKFo7Vg8
-> done
""", stream.output)
}
func testRecipientError() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> add-recipient age1invalid1q0mm28s88km3d8fvwve26xg4tt26cqamhxm79g9xvmw0f2erawj75hkckfk
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> error recipient 1
Q2hlY2tzdW0gZG9lc24ndCBtYXRjaA
-> done
""", stream.output)
}
func testIdentityError() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> add-identity AGE-PLUGIN-INVALID-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHS2FM3SW
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> error identity 1
Q2hlY2tzdW0gZG9lc24ndCBtYXRjaA
-> done
""", stream.output)
}
func testInvalidRecipientHRP() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1vld7p2khw44ds8t00vcfmjdf35zxqvn2trjccd35h4s22faj94vsjhn620
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> error recipient 0
dW5rbm93biBIUlA6IGFnZQ
-> done
""", stream.output)
}
// func testFailingCryptoOperations() throws {
// let plugin = Plugin(crypto: crypto, stream: stream)
// stream.add(
// input:
// """
// -> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
// -> wrap-file-key
// AAAAAAAAAAAAAAAAAAAAAQ
// -> done
// -> ok
// """)
// crypto.failingOperations = true
// plugin.runRecipientV1()
// XCTAssertEqual(
// """
// -> error internal
// ZHVtbXkgZXJyb3I
// -> done
// """, stream.output)
// }
func testUnknownStanzaTypes() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-recipient age1se1qf0l9gks6x65ha077wq3w3u8fy02tpg3cd9w5j0jlgpfgqkcut2lw6hta9l
-> unknown-stanza 1 2 3
-> wrap-file-key
AAAAAAAAAAAAAAAAAAAAAQ
-> anotherunknownstanza
AAAAAAAAAAAAAAAAAAAAAQ
-> done
-> ok
""")
plugin.runRecipientV1()
XCTAssertEqual(
"""
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
""", stream.output)
}
}
final class IdentityV1Tests: XCTestCase {
var stream = MemoryStream()
var crypto = DummyCrypto()
override func setUp() {
stream = MemoryStream()
crypto = DummyCrypto()
}
func testNothing() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(input: "-> done\n")
plugin.runIdentityV1()
XCTAssertEqual("-> done\n", stream.output)
}
func testRecipientStanza() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> done
""", stream.output)
}
func testRecipientStanzaMultipleFiles() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 1 piv-p256 14yi6A AvEp8Oz0cMnXhpXnWM6cwer4nEDHus/AvNp3kYnUH0Qs
L3ig8s2AqjusH/0lW6ZueSEYhpeV2ofrQpaKP06WI9g
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> file-key 1
AAAAAAAAAAAAAAAAAAAAAg
-> done
""", stream.output)
}
func testRecipientStanzaMultipleFilesMultipleIdentities() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> add-identity AGE-PLUGIN-SE-1JQDEPT8UN77Z4Q2UNERWRQRY4RG3RK6DV7YG0R562HKY2TDQCNWSREKAW7
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> recipient-stanza 1 piv-p256 14yi6A AvEp8Oz0cMnXhpXnWM6cwer4nEDHus/AvNp3kYnUH0Qs
L3ig8s2AqjusH/0lW6ZueSEYhpeV2ofrQpaKP06WI9g
-> recipient-stanza 1 piv-p256 1mgwOA AoIMpSYaKzGl5IBFaM9AFJXmrseGzTzcQjS9R4kRcjRi
vm8flaP+4W08S6LwFENwnEKLlpzZ5YqZ3NdpKFo7Vg8
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> file-key 1
AAAAAAAAAAAAAAAAAAAAAg
-> done
""", stream.output)
}
func testRecipientStanzaMultipleStanzasMissingIdentity() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-1JQDEPT8UN77Z4Q2UNERWRQRY4RG3RK6DV7YG0R562HKY2TDQCNWSREKAW7
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> done
""", stream.output)
}
func testRecipientStanza_UnknownType() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 X25519 A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> done
""", stream.output)
}
func testIdentityError() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> add-identity AGE-PLUGIN-INVALID-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHS2FM3SW
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error identity 1
Q2hlY2tzdW0gZG9lc24ndCBtYXRjaA
-> done
""", stream.output)
}
func testUnknownIdentityHRP() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> add-identity AGE-SECRET-KEY-1MCFVWZK6PK625PWMWVYPZDQM4N7AS3VA754JHCC60ZT7WJ79TQQSQDYVGF
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error identity 1
dW5rbm93biBIUlA6IEFHRS1TRUNSRVQtS0VZLQ
-> done
""", stream.output)
}
func testRecipientStanzaMultipleFilesStructurallyInvalidFile() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> add-identity AGE-PLUGIN-SE-1JQDEPT8UN77Z4Q2UNERWRQRY4RG3RK6DV7YG0R562HKY2TDQCNWSREKAW7
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> recipient-stanza 1 piv-p256 14yi6A AvEp8Oz0cMnXhpXnWM6cwer4nEDHus/AvNp3kYnUH0Qs
L3ig8s2AqjusH/0lW6ZueSEYhpeV2ofrQpaKP06WI9g
-> recipient-stanza 1 piv-p256 1mgwOA AoIMpSYaKzGl5IBFaM9AFJXmrseGzTzcQjS9R4kRcjRi
vm8flaP+4W08S6LwFENwnEKLlpzZ5YqZ3NdpKFo7Vg8
-> done
-> ok
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error stanza 0
aW5jb3JyZWN0IGFyZ3VtZW50IGNvdW50
-> file-key 1
AAAAAAAAAAAAAAAAAAAAAg
-> done
""", stream.output)
}
func testRecipientStanzaInvalidStructure_ArgumentCount() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 1mgwOA
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error stanza 0
aW5jb3JyZWN0IGFyZ3VtZW50IGNvdW50
-> done
""", stream.output)
}
func testRecipientStanzaInvalidTag() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 14yi Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error stanza 0
aW52YWxpZCB0YWc
-> done
""", stream.output)
}
func testRecipientStanzaInvalidShare() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5Q
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error stanza 0
aW52YWxpZCBzaGFyZQ
-> done
""", stream.output)
}
func testRecipientStanzaInvalidBody() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
AAAAAAAAAAAAAAAAAAAAARIiJq2e9+1E+xK92Pvdtw
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> error stanza 0
aW52YWxpZCBib2R5
-> done
""", stream.output)
}
func testFailingCryptoOperations() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> add-identity AGE-PLUGIN-SE-1JQDEPT8UN77Z4Q2UNERWRQRY4RG3RK6DV7YG0R562HKY2TDQCNWSREKAW7
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> recipient-stanza 0 piv-p256 1mgwOA A1x2nUpw2wo/7z0JR5puskK6NuvW5XkQBwkun/T3WC80
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> done
-> ok
-> ok
-> ok
""")
crypto.failingOperations = true
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> msg
ZHVtbXkgZXJyb3I
-> msg
ZHVtbXkgZXJyb3I
-> done
""", stream.output)
}
func testUnknownStanzas() throws {
let plugin = Plugin(crypto: crypto, stream: stream)
stream.add(
input:
"""
-> unknown-stanza-1 a bbb c
-> add-identity AGE-PLUGIN-SE-18YNMANPJKHE2ZAZJHRCKZKFXCT78YYWUTY0F730TMTZFV0CM9YHSRP8GPG
-> unknown-stanza-2
9NGkkBZykDMgw6dndbbjnn7DQBalVV4sVIurWku030Y
-> recipient-stanza 0 piv-p256 14yi6A Az7IeMpB4oX0CHt/Bc9xzk6x1K262zNxoUtfAikZa5T7
SLgnrcnHLaJHCx+fwSEWWoflDgL91oDGCGNwb2YaT+4
-> done
-> ok
""")
plugin.runIdentityV1()
XCTAssertEqual(
"""
-> file-key 0
AAAAAAAAAAAAAAAAAAAAAQ
-> done
""", stream.output)
}
}

208
Tests/StanzaTests.swift Normal file
View File

@@ -0,0 +1,208 @@
import XCTest
@testable import age_plugin_se
final class StanzaTests: XCTestCase {
var stream = MemoryStream()
override func setUp() {
stream = MemoryStream()
}
func testReadFrom() throws {
stream.add(
input:
"""
-> mytype MyArgument1 MyArgument2
TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
bmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFi
b3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVu
aWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uIHVsbGFtY28gbGFib3JpcyBu
aXNpIHV0IGFsaXF1aXAgZXggZWEgY29tbW9kbyBjb25zZXF1YXQuIER1aXMgYXV0
ZSBpcnVyZSBkb2xvciBpbiByZXByZWhlbmRlcml0IGluIHZvbHVwdGF0ZSB2ZWxp
dCBlc3NlIGNpbGx1bSBkb2xvcmUgZXUgZnVnaWF0IG51bGxhIHBhcmlhdHVyLiBF
eGNlcHRldXIgc2ludCBvY2NhZWNhdCBjdXBpZGF0YXQgbm9uIHByb2lkZW50LCBz
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlk
IGVzdCBsYWJvcnVtLg
""")
XCTAssertEqual(
Stanza(
type: "mytype",
args: ["MyArgument1", "MyArgument2"],
body:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
.data(using: .utf8)!
), try Stanza.readFrom(stream: stream))
}
func testReadFrom_EmptyBody() throws {
stream.add(
input:
"""
-> mytype
""")
XCTAssertEqual(
Stanza(
type: "mytype",
args: [],
body: Data()
), try Stanza.readFrom(stream: stream))
}
func testReadFrom_EmptyLastLine() throws {
stream.add(
input:
"""
-> mystanza
TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
""")
XCTAssertEqual(
Stanza(
type: "mystanza",
args: [],
body:
"Lorem ipsum dolor sit amet, consectetur adipisci"
.data(using: .utf8)!
), try Stanza.readFrom(stream: stream))
}
func testReadFrom_MissingType() throws {
stream.add(
input:
"""
->
IGVzdCBsYWJvcnVtLg
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.invalidStanza)
}
}
func testReadFrom_InvalidPrefix() throws {
stream.add(
input:
"""
=> mystanza
IGVzdCBsYWJvcnVtLg
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.invalidStanza)
}
}
func testReadFrom_BodyTooLong() throws {
stream.add(
input:
"""
-> mystanza
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlkIGVzdCBsYWJvcnVtLg
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.invalidStanza)
}
}
func testReadFrom_BodyInvalid() throws {
stream.add(
input:
"""
-> mystanza
_dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlkIGVzdCBsYWJvcnVtLg
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.invalidStanza)
}
}
func testReadFrom_BodyIncomplete() throws {
stream.add(
input:
"""
-> mystanza
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlk
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.incompleteStanza)
}
}
func testReadFrom_BodyMissing() throws {
stream.add(
input:
"""
-> mystanza
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.incompleteStanza)
}
}
func testReadFrom_BodyHasPadding() throws {
stream.add(
input:
"""
=> mystanza
IGVzdCBsYWJvcnVtLg==
""")
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.invalidStanza)
}
}
func testReadFrom_NoInput() throws {
XCTAssertThrowsError(try Stanza.readFrom(stream: stream)) { error in
XCTAssertEqual(error as! Plugin.Error, Plugin.Error.incompleteStanza)
}
}
func testWriteTo() throws {
Stanza(
type: "mytype",
args: ["MyArgument1", "MyArgument2"],
body:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
.data(using: .utf8)!
).writeTo(stream: stream)
XCTAssertEqual(
"""
-> mytype MyArgument1 MyArgument2
TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2Np
bmcgZWxpdCwgc2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFi
b3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVu
aWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uIHVsbGFtY28gbGFib3JpcyBu
aXNpIHV0IGFsaXF1aXAgZXggZWEgY29tbW9kbyBjb25zZXF1YXQuIER1aXMgYXV0
ZSBpcnVyZSBkb2xvciBpbiByZXByZWhlbmRlcml0IGluIHZvbHVwdGF0ZSB2ZWxp
dCBlc3NlIGNpbGx1bSBkb2xvcmUgZXUgZnVnaWF0IG51bGxhIHBhcmlhdHVyLiBF
eGNlcHRldXIgc2ludCBvY2NhZWNhdCBjdXBpZGF0YXQgbm9uIHByb2lkZW50LCBz
dW50IGluIGN1bHBhIHF1aSBvZmZpY2lhIGRlc2VydW50IG1vbGxpdCBhbmltIGlk
IGVzdCBsYWJvcnVtLg
""", stream.output)
}
func testWriteTo_NoArguments() throws {
Stanza(
type: "mytype",
body: "Lorem ipsum".data(using: .utf8)!
).writeTo(stream: stream)
XCTAssertEqual(
"""
-> mytype
TG9yZW0gaXBzdW0
""", stream.output)
}
func testWriteTo_EmptyBody() throws {
Stanza(
type: "mytype",
args: [],
body: Data()
).writeTo(stream: stream)
XCTAssertEqual(
"""
-> mytype
""", stream.output)
}
}