from: github.com/remko/age-plugin-se
This commit is contained in:
19
Documentation/Design.md
Normal file
19
Documentation/Design.md
Normal 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.
|
||||
34
Documentation/img/coverage.svg
Normal file
34
Documentation/img/coverage.svg
Normal 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="'DejaVu Sans',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="'DejaVu Sans',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 |
BIN
Documentation/img/screenshot-biometry-or-passcode.png
Normal file
BIN
Documentation/img/screenshot-biometry-or-passcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
Documentation/img/screenshot-biometry.png
Normal file
BIN
Documentation/img/screenshot-biometry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal 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
118
Makefile
Normal 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
36
Package.swift
Normal 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"),
|
||||
]
|
||||
)
|
||||
99
README.md
99
README.md
@@ -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
|
||||
|
||||
[](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
36
Sources/Base64.swift
Normal 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
235
Sources/Bech32.swift
Normal 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
173
Sources/CLI.swift
Normal 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
78
Sources/Crypto.swift
Normal 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
414
Sources/Plugin.swift
Normal 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
19
Sources/Stream.swift
Normal 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
79
Tests/Base64Tests.swift
Normal 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
103
Tests/Bech32Tests.swift
Normal 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
49
Tests/CLITests.swift
Normal 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
71
Tests/CryptoTests.swift
Normal 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
75
Tests/DummyCrypto.swift
Normal 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
27
Tests/MemoryStream.swift
Normal 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
828
Tests/PluginTests.swift
Normal 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
208
Tests/StanzaTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user