Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Update 11 Feature] Add support for PGP encryption/decryption with streams #567

Merged
merged 47 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0c230e2
[Automated] Update the native jar versions
TharmiganK Oct 2, 2024
85c3173
[Automated] Update the native jar versions
TharmiganK Oct 2, 2024
3ac65fd
Add PGP encrypt/decrypt functions with files
TharmiganK Oct 2, 2024
0fbae6b
Apply suggestions from code review
TharmiganK Oct 2, 2024
ae7af8c
[Automated] Update the native jar versions
TharmiganK Oct 2, 2024
3deeba8
Add test cases
TharmiganK Oct 2, 2024
c930edd
Update change log
TharmiganK Oct 2, 2024
04ba4ca
Update spec
TharmiganK Oct 2, 2024
3654143
Update to next minor version
TharmiganK Oct 2, 2024
1fa0552
[Automated] Update the native jar versions
TharmiganK Oct 2, 2024
95f34c1
[Automated] Update the native jar versions
TharmiganK Oct 2, 2024
957f623
Add IO test dependency
TharmiganK Oct 2, 2024
5ba88d7
Add suggestions from review
TharmiganK Oct 2, 2024
e207f95
Change error message
TharmiganK Oct 2, 2024
18ff4cc
Update time version
TharmiganK Oct 2, 2024
95c6beb
Add stream pgp decrypt support
TharmiganK Oct 7, 2024
7c584bb
Fix casting issue
TharmiganK Oct 7, 2024
d09e235
[Automated] Update the native jar versions
TharmiganK Oct 8, 2024
1126113
[Automated] Update the native jar versions
TharmiganK Oct 8, 2024
ea708af
Add stream pgp encrypt support
TharmiganK Oct 9, 2024
7094e49
Replace PipedStreams with a custom implementation
TharmiganK Oct 9, 2024
b635c45
Remove PGP file APIs
TharmiganK Oct 9, 2024
9cdd933
Fix test cases
TharmiganK Oct 9, 2024
29333dc
Update change log
TharmiganK Oct 9, 2024
f71ebb7
Update spec
TharmiganK Oct 9, 2024
9169721
Update ballerina invoke call
TharmiganK Oct 9, 2024
be0f1cb
Merge remote-tracking branch 'origin/java21' into pgp-files
TharmiganK Oct 9, 2024
1ba983d
[Automated] Update the native jar versions
TharmiganK Oct 9, 2024
132cd31
[Automated] Update the native jar versions
TharmiganK Oct 9, 2024
a51aa97
[Automated] Update the native jar versions
TharmiganK Oct 9, 2024
7139227
Update dependency versions supported with Java 21
TharmiganK Oct 9, 2024
43b3903
Rename test functions
TharmiganK Oct 9, 2024
7c429ca
Add license header
TharmiganK Oct 9, 2024
9d529a4
Add module prefix
TharmiganK Oct 9, 2024
6902dbe
Address sonar cloud issues
TharmiganK Oct 9, 2024
534c5a0
Fix integrity check
TharmiganK Oct 9, 2024
a443409
Apply suggestions from code review
TharmiganK Oct 10, 2024
c792890
Remove unused constants
TharmiganK Oct 10, 2024
0f85fef
Rename PGP stream APIs
TharmiganK Oct 11, 2024
c9aca36
Merge remote-tracking branch 'origin/master' into pgp-files
TharmiganK Oct 11, 2024
03c6d8f
Apply suggestions from code review
TharmiganK Oct 11, 2024
f019b54
Add missing doc for options
TharmiganK Oct 14, 2024
db345a0
Replace experimental graalvm options
TharmiganK Oct 14, 2024
6e35d66
Merge remote-tracking branch 'origin/master' into pgp-files
TharmiganK Nov 18, 2024
a333117
Apply suggestions from code review
TharmiganK Nov 18, 2024
9deb3cd
[Automated] Update the native jar versions
TharmiganK Nov 18, 2024
df0e19d
Update lang APIs
TharmiganK Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ballerina/Ballerina.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
org = "ballerina"
name = "crypto"
version = "2.7.2"
version = "2.8.0"
authors = ["Ballerina"]
keywords = ["security", "hash", "hmac", "sign", "encrypt", "decrypt", "private key", "public key"]
repository = "https://github.com/ballerina-platform/module-ballerina-crypto"
Expand All @@ -15,8 +15,8 @@ graalvmCompatible = true
[[platform.java17.dependency]]
groupId = "io.ballerina.stdlib"
artifactId = "crypto-native"
version = "2.7.2"
path = "../native/build/libs/crypto-native-2.7.2.jar"
version = "2.8.0"
path = "../native/build/libs/crypto-native-2.8.0-SNAPSHOT.jar"

[[platform.java17.dependency]]
groupId = "org.bouncycastle"
Expand Down
25 changes: 24 additions & 1 deletion ballerina/Dependencies.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ distribution-version = "2201.9.0"
[[package]]
org = "ballerina"
name = "crypto"
version = "2.7.2"
version = "2.8.0"
dependencies = [
{org = "ballerina", name = "io"},
{org = "ballerina", name = "jballerina.java"},
{org = "ballerina", name = "lang.array"},
{org = "ballerina", name = "test"},
Expand All @@ -21,6 +22,19 @@ modules = [
{org = "ballerina", packageName = "crypto", moduleName = "crypto"}
]

[[package]]
org = "ballerina"
name = "io"
version = "1.6.1"
scope = "testOnly"
dependencies = [
{org = "ballerina", name = "jballerina.java"},
{org = "ballerina", name = "lang.value"}
]
modules = [
{org = "ballerina", packageName = "io", moduleName = "io"}
]

[[package]]
org = "ballerina"
name = "jballerina.java"
Expand Down Expand Up @@ -67,6 +81,15 @@ name = "lang.object"
version = "0.0.0"
scope = "testOnly"

[[package]]
org = "ballerina"
name = "lang.value"
version = "0.0.0"
scope = "testOnly"
dependencies = [
{org = "ballerina", name = "jballerina.java"}
]

[[package]]
org = "ballerina"
name = "test"
Expand Down
33 changes: 33 additions & 0 deletions ballerina/encrypt_decrypt.bal
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,23 @@ public isolated function encryptPgp(byte[] plainText, string publicKeyPath, *Opt
'class: "io.ballerina.stdlib.crypto.nativeimpl.Encrypt"
} external;

# Writes the PGP-encrypted value of the given data to a file specified by the output file path.
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
# ```ballerina
# check crypto:encryptPgpAsFile("input.txt", "public_key.asc", "output.txt");
# ```
#
# + inputFilePath - Path to the input file
# + publicKeyPath - Path to the public key
# + outputFilePath - Path to the output file
# + options - PGP encryption options
# + return - A `crypto:Error` will be returned if the process fails
public isolated function encryptPgpAsFile(string inputFilePath, string publicKeyPath, string outputFilePath,
*Options options) returns Error? = @java:Method {
'class: "io.ballerina.stdlib.crypto.nativeimpl.Encrypt"
} external;

# Returns the PGP-decrypted value of the given PGP-encrypted data.
# If the output file already exists, it will be overwritten.
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
# ```ballerina
# byte[] message = "Hello Ballerina!".toBytes();
# byte[] cipherText = check crypto:encryptPgp(message, "public_key.asc");
Expand All @@ -278,3 +294,20 @@ public isolated function decryptPgp(byte[] cipherText, string privateKeyPath, by
name: "decryptPgp",
'class: "io.ballerina.stdlib.crypto.nativeimpl.Decrypt"
} external;

# Writes the PGP-decrypted value of the given data to a file specified by the output file path.
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
# If the output file already exists, it will be overwritten.
# ```ballerina
# byte[] passphrase = check io:fileReadBytes("pass_phrase.txt");
# check crypto:decryptPgpAsFile("input.txt", "private_key.asc", passphrase, "output.txt");
# ```
#
# + inputFilePath - Path to the input file
# + privateKeyPath - Path to the private key
# + passphrase - passphrase of the private key
# + outputFilePath - Path to the output file
# + return - A `crypto:Error` will be returned if the process fails
public isolated function decryptPgpAsFile(string inputFilePath, string privateKeyPath, byte[] passphrase,
string outputFilePath) returns Error? = @java:Method {
'class: "io.ballerina.stdlib.crypto.nativeimpl.Decrypt"
} external;
4 changes: 2 additions & 2 deletions ballerina/private_public_key.bal
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ public isolated function decodeRsaPrivateKeyFromKeyFile(string keyFile, string?
# crypto:PrivateKey privateKey = check crypto:decodeRsaPrivateKeyFromContent(keyFileContent, "keyPassword");
# ```
#
# + keyFile - Private key content as a byte array
# + content - Private key content as a byte array
# + keyPassword - Password of the private key if it is encrypted
# + return - Reference to the private key or else a `crypto:Error` if the private key was unreadable
public isolated function decodeRsaPrivateKeyFromContent(byte[] content, string? keyPassword = ()) returns PrivateKey|Error = @java:Method {
Expand Down Expand Up @@ -311,7 +311,7 @@ public isolated function decodeRsaPublicKeyFromCertFile(string certFile) returns
# crypto:PublicKey publicKey = check crypto:decodeRsaPublicKeyFromContent(certContent);
# ```
#
# + certFile - The certificate content as a byte array
# + content - The certificate content as a byte array
# + return - Reference to the public key or else a `crypto:Error` if the public key was unreadable
public isolated function decodeRsaPublicKeyFromContent(byte[] content) returns PublicKey|Error = @java:Method {
'class: "io.ballerina.stdlib.crypto.nativeimpl.Decode"
Expand Down
58 changes: 57 additions & 1 deletion ballerina/tests/encrypt_decrypt_pgp_test.bal
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// specific language governing permissions and limitations
// under the License.

import ballerina/io;
import ballerina/test;

@test:Config {}
Expand Down Expand Up @@ -55,8 +56,63 @@ isolated function testNegativeEncryptAndDecryptWithPgpInvalidPassphrase() return
byte[]|Error plainText = decryptPgp(cipherText, PGP_PRIVATE_KEY_PATH, passphrase);
if plainText is Error {
test:assertEquals(plainText.message(),
"Error occurred while PGP decrypt: checksum mismatch at in checksum of 20 bytes");
"Error occurred while PGP decrypt: checksum mismatch at in checksum of 20 bytes");
} else {
test:assertFail("Should return a crypto Error");
}
}

@test:Config {
serialExecution: true
}
isolated function testEncryptAndDecryptFileWithPgp() returns error? {
byte[] passphrase = "qCr3bv@5mj5n4eY".toBytes();
check encryptPgpAsFile(SAMPLE_TEXT, PGP_PUBLIC_KEY_PATH, TARGET_ENCRYPTION_OUTPUT);
check decryptPgpAsFile(TARGET_ENCRYPTION_OUTPUT, PGP_PRIVATE_KEY_PATH, passphrase, TARGET_DECRYPTION_OUTPUT);
test:assertTrue(check isSameFileContent(SAMPLE_TEXT, TARGET_DECRYPTION_OUTPUT));
}

@test:Config {
serialExecution: true
}
isolated function testEncryptAndDecryptFileWithPgpWithOptions() returns error? {
byte[] passphrase = "qCr3bv@5mj5n4eY".toBytes();
check encryptPgpAsFile(SAMPLE_TEXT, PGP_PUBLIC_KEY_PATH, TARGET_ENCRYPTION_OUTPUT, symmetricKeyAlgorithm = AES_128, armor = false);
check decryptPgpAsFile(TARGET_ENCRYPTION_OUTPUT, PGP_PRIVATE_KEY_PATH, passphrase, TARGET_DECRYPTION_OUTPUT);
test:assertTrue(check isSameFileContent(SAMPLE_TEXT, TARGET_DECRYPTION_OUTPUT));
}

@test:Config {
serialExecution: true
}
isolated function testNegativeEncryptAndDecryptFileWithPgpInvalidPrivateKey() returns error? {
byte[] passphrase = "p7S5@T2MRFD9TQb".toBytes();
check encryptPgpAsFile(SAMPLE_TEXT, PGP_PUBLIC_KEY_PATH, TARGET_ENCRYPTION_OUTPUT);
error? err = decryptPgpAsFile(TARGET_ENCRYPTION_OUTPUT, PGP_INVALID_PRIVATE_KEY_PATH, passphrase, TARGET_DECRYPTION_OUTPUT);
if err is Error {
test:assertEquals(err.message(), "Error occurred while PGP decrypt: Could Not Extract private key");
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
} else {
test:assertFail("Should return a crypto Error");
}
}

@test:Config {
serialExecution: true
}
isolated function testNegativeEncryptAndDecryptFileWithPgpInvalidPassphrase() returns error? {
byte[] passphrase = "p7S5@T2MRFD9TQb".toBytes();
check encryptPgpAsFile(SAMPLE_TEXT, PGP_PUBLIC_KEY_PATH, TARGET_ENCRYPTION_OUTPUT);
error? err = decryptPgpAsFile(TARGET_ENCRYPTION_OUTPUT, PGP_PRIVATE_KEY_PATH, passphrase, TARGET_DECRYPTION_OUTPUT);
if err is Error {
test:assertEquals(err.message(),
"Error occurred while PGP decrypt: checksum mismatch at in checksum of 20 bytes");
} else {
test:assertFail("Should return a crypto Error");
}
}

isolated function isSameFileContent(string inputFilePath, string outputFilePath) returns boolean|error {
byte[] input = check io:fileReadBytes(inputFilePath);
byte[] output = check io:fileReadBytes(outputFilePath);
return input.toBase64() == output.toBase64();
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
}
12 changes: 12 additions & 0 deletions ballerina/tests/resources/sample.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

Ballerina is an open-source programming language designed for cloud-native application development. It combines features for integration, service orchestration, and network interaction, with a focus on ease of use for building APIs, managing data, and deploying in distributed environments. Ballerina's syntax and built-in concurrency support make it well-suited for creating robust, scalable, and secure services.

Ballerina adopts a developer-friendly approach by incorporating modern programming constructs, such as structural typing, flexible JSON handling, and a familiar C-style syntax, which reduces the learning curve for developers. The language has first-class support for network primitives, allowing developers to directly work with network protocols like HTTP, WebSockets, and gRPC without the need for additional libraries. This direct handling of network interactions makes Ballerina ideal for writing microservices and integrating with other systems effortlessly.

Ballerina also features built-in support for distributed transactions, reliable messaging, and data transformations, making it suitable for integration-heavy applications. Its built-in observability tools, including metrics, logs, and distributed tracing, help developers monitor and debug applications efficiently. Ballerina is inherently cloud-native, with easy containerization and Kubernetes deployment support, simplifying the process of deploying services in modern cloud environments.

The concurrency model in Ballerina is based on the concept of "strands," which are lightweight threads managed by the language runtime. This model allows developers to write concurrent code using simple constructs, such as asynchronous functions and workers, without worrying about low-level threading concerns. This makes it easier to develop applications that are responsive and scalable, capable of handling high loads and concurrent user interactions.

Ballerina’s ecosystem includes various tools, such as the Ballerina Central registry, which provides a platform for sharing and discovering packages. The language’s visual representation of code through sequence diagrams is another unique feature, enabling both developers and non-developers to better understand program behavior, especially for integration logic. Ballerina's compiler can generate these diagrams automatically, which is beneficial for documentation and analysis of workflows.

Furthermore, Ballerina's support for data-oriented programming makes it easy to transform and manipulate structured data formats like JSON, XML, and SQL. This, along with the language’s built-in type system that directly represents these data types, reduces the need for complex data mapping and serialization tasks. With support for RESTful APIs, GraphQL, and multiple database connectors, Ballerina is designed to provide seamless integration capabilities, making it an excellent choice for businesses looking to modernize their IT landscape with cloud-native services.
4 changes: 4 additions & 0 deletions ballerina/tests/test_utils.bal
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ const string PGP_PUBLIC_KEY_PATH = "tests/resources/public_key.asc";
const string PGP_PRIVATE_KEY_PATH = "tests/resources/private_key.asc";
const string PGP_INVALID_PRIVATE_KEY_PATH = "tests/resources/invalid_private_key.asc";
const string PGP_PRIVATE_KEY_PASSPHRASE_PATH = "tests/resources/pgp_private_key_passphrase.txt";

const string SAMPLE_TEXT = "tests/resources/sample.txt";
const string TARGET_ENCRYPTION_OUTPUT = "target/encrypted_output.txt";
const string TARGET_DECRYPTION_OUTPUT = "target/decrypted_output.txt";
TharmiganK marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- [Introduce new APIs to support PGP encryption and decryption with files](https://github.com/ballerina-platform/ballerina-library/issues/7064)

## [2.7.2] - 2024-05-30

### Added
- [Implement the support for reading private/public keys from the content](https://github.com/ballerina-platform/ballerina-library/issues/6513)

Expand Down
70 changes: 69 additions & 1 deletion docs/spec/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,19 @@ The conforming implementation of the specification is released and included in t
* 4.17. [Decode ML-KEM-768 Private key using Private key and Password](#417-decode-ml-kem-768-private-key-using-private-key-and-password)
* 4.18. [Decode ML-KEM-768 Public key from PKCS12 file](#418-decode-ml-kem-768-public-key-from-pkcs12-file)
* 4.19. [Decode ML-KEM-768 Public key from the certificate file](#419-decode-ml-kem-768-public-key-from-the-certificate-file)

5. [Encrypt-Decrypt](#5-encrypt-decrypt)
* 5.1. [Encryption](#51-encryption)
* 5.1.1. [RSA](#511-rsa)
* 5.1.2. [AES-CBC](#512-aes-cbc)
* 5.1.3. [AES-ECB](#513-aes-ecb)
* 5.1.4. [AES-GCM](#514-aes-gcm)
* 5.1.5. [PGP](#515-pgp)
* 5.2. [Decryption](#52-decryption)
* 5.2.1. [RSA-ECB](#521-rsa-ecb)
* 5.2.2. [AES-CBC](#522-aes-cbc)
* 5.2.3. [AES-ECB](#523-aes-ecb)
* 5.2.4. [AES-GCM](#524-aes-gcm)
* 5.2.5. [PGP](#525-pgp)
6. [Sign and Verify](#6-sign-and-verify)
* 6.1. [Sign messages](#61-sign-messages)
* 6.1.1. [RSA-MD5](#611-rsa-md5)
Expand Down Expand Up @@ -502,6 +503,46 @@ foreach int i in 0...15 {
byte[] cipherText = check crypto:encryptAesGcm(data, key, initialVector);
```

#### 5.1.5. [PGP](#515-pgp)

This API can be used to create the PGP-encrypted value for the given data.

```ballerina
string input = "Hello Ballerina";
byte[] data = input.toBytes();
string publicKeyPath = "/path/to/publickey.asc";

byte[] cipherText = check crypto:encryptPgp(data, publicKeyPath);
```

The following encryption options can be configured in the PGP encryption.

| Option | Description | Default Value |
|-----------------------|-------------------------------------------------------------------|---------------|
| compressionAlgorithm | Specifies the compression algorithm used for PGP encryption | ZIP |
| symmetricKeyAlgorithm | Specifies the symmetric key algorithm used for encryption | AES_256 |
| armor | Indicates whether ASCII armor is enabled for the encrypted output | true |
| withIntegrityCheck | Indicates whether integrity check is included in the encryption | true |

```ballerina
string input = "Hello Ballerina";
byte[] data = input.toBytes();
string publicKeyPath = "/path/to/publickey.asc";

byte[] cipherText = check crypto:encryptPgp(data, publicKeyPath, armor = false);
```

In addition to the above, the following API can be used to read a content from a file, encrypt it using the PGP public
key and write the encrypted content to the file specified.

```ballerina
string inputFilePath = "/path/to/input.txt";
string outputFilePath = "/path/to/output.txt";
string publicKeyPath = "/path/to/publickey.asc";

check crypto:encryptPgpAsFile(inputFilePath, publicKeyPath, outputFilePath);
```

### 5.2. [Decryption](#52-decryption)

#### 5.2.1. [RSA-ECB](#521-rsa-ecb)
Expand Down Expand Up @@ -574,6 +615,33 @@ byte[] cipherText = check crypto:encryptAesGcm(data, key, initialVector);
byte[] plainText = check crypto:decryptAesGcm(cipherText, key, initialVector);
```

#### 5.2.5. [PGP](#525-pgp)

This API can be used to create the PGP-decrypted value for the given PGP-encrypted data.

```ballerina
string input = "Hello Ballerina";
byte[] data = input.toBytes();
string publicKeyPath = "/path/to/publickey.asc";
string privateKeyPath = "/path/to/privatekey.asc";
string passPhrase = "passphrase";

byte[] cipherText = check crypto:encryptPgp(data, publicKeyPath);
byte[] plainText = check crypto:decryptPgp(cipherText, privateKeyPath, passPhrase.toBytes());
```

In addition to the above, the following API can be used to read an encrypted content from a file, decrypt it using the
PGP private key and passphrase and write the decrypted content to the file specified.

```ballerina
string inputFilePath = "/path/to/input.txt";
string outputFilePath = "/path/to/output.txt";
string privateKeyPath = "/path/to/privatekey.asc";
string passPhrase = "passphrase";

check crypto:decryptPgpAsFile(inputFilePath, privateKeyPath, passPhrase.toBytes(), outputFilePath);
```

## 6. [Sign and Verify](#6-sign-and-verify)

The `crypto` library supports signing data using the RSA private key and verification of the signature using the RSA public key. This supports MD5, SHA1, SHA256, SHA384, and SHA512 digesting algorithms, and ML-DSA-65 post-quantum signature algorithm as well.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
org.gradle.caching=true
group=io.ballerina.stdlib
version=2.7.3-SNAPSHOT
version=2.8.0-SNAPSHOT
puppycrawlCheckstyleVersion=10.12.0
bouncycastleVersion=1.78
githubSpotbugsVersion=5.0.14
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.util.Iterator;
import java.util.Objects;
Expand Down Expand Up @@ -123,6 +125,12 @@ public Object decrypt(byte[] encryptedBytes) throws PGPException, IOException {
}
}

public void decrypt(InputStream encryptedIn, String outputPath) throws PGPException, IOException {
try (OutputStream outputStream = Files.newOutputStream(Path.of(outputPath))) {
decryptStream(encryptedIn, outputStream);
}
}

private static void decrypt(OutputStream clearOut, Optional<PGPPrivateKey> pgpPrivateKey,
PGPPublicKeyEncryptedData publicKeyEncryptedData) throws IOException, PGPException {
if (pgpPrivateKey.isPresent()) {
Expand Down
Loading
Loading