Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .semaphore/semaphore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ blocks:
fi
done
- open -Fn "$(xcode-select -p)/Applications/Simulator.app"
- xcrun simctl boot "iPhone 16"
- xcrun simctl boot "iPhone 17"
- xcrun simctl install booted ./WebDriverAgentRunner-Runner.app
epilogue:
always:
Expand Down
39 changes: 25 additions & 14 deletions Core/source/mobile-interface/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,30 @@ export class Endpoints {
} else if (req.format === 'encryptInline') {
const encryptedAtts: Att[] = [];
for (const att of req.atts || []) {
const encryptedAtt = (await PgpMsg.encrypt({
pubkeys: req.pubKeys,
data: Buf.fromBase64Str(att.base64),
filename: att.name,
armor: false,
})) as Uint8Array;
encryptedAtts.push(
new Att({
name: `${att.name}.pgp`,
type: 'application/pgp-encrypted',
data: encryptedAtt,
}),
);
// Skip encryption for public key attachments
if (att.type === 'application/pgp-keys') {
encryptedAtts.push(
new Att({
name: att.name,
type: att.type,
data: Buf.fromBase64Str(att.base64),
}),
);
} else {
const encryptedAtt = (await PgpMsg.encrypt({
pubkeys: req.pubKeys,
data: Buf.fromBase64Str(att.base64),
filename: att.name,
armor: false,
})) as Uint8Array;
encryptedAtts.push(
new Att({
name: `${att.name}.pgp`,
type: 'application/pgp-encrypted',
data: encryptedAtt,
}),
);
}
}

const signingPrv = await getSigningPrv(req);
Expand Down Expand Up @@ -199,7 +210,7 @@ export class Endpoints {
type: 'decryptErr',
content:
decryptRes.error.type === DecryptErrTypes.noMdc
? decryptRes.content?.toUtfStr() ?? ''
? (decryptRes.content?.toUtfStr() ?? '')
: rawBlock.content.toString(),
decryptErr: decryptRes,
complete: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ extension ComposeViewController {
handler: { [weak self] _ in self?.selectFromFilesApp() }
)
)
let publicKeyAction = UIAlertAction(
title: "files_picking_public_key".localized,
style: .default,
handler: { [weak self] _ in self?.attachPublicKey() }
)
publicKeyAction.accessibilityIdentifier = "aid-attach-public-key"
alert.addAction(publicKeyAction)
alert.addAction(UIAlertAction(title: "cancel".localized, style: .cancel))
present(alert, animated: true, completion: nil)
}
Expand Down Expand Up @@ -74,4 +81,45 @@ extension ComposeViewController {
}
)
}

private func attachPublicKey() {
Task {
do {
// Get the current user's keypair
let keypair = try await getUserKeypair()

// Get the public key data
let publicKeyArmored = keypair.public
guard let publicKeyData = publicKeyArmored.data(using: String.Encoding.utf8) else {
throw AppErr.general("Failed to convert public key to data")
}

// Create a MessageAttachment from the public key data with longid-based filename
let attachment = MessageAttachment(
name: "0x\(keypair.primaryLongid).asc",
data: publicKeyData,
mimeType: "application/pgp-keys"
)
appendAttachmentIfAllowed(attachment)
reload(sections: [.attachments])
} catch {
showAlert(message: "Failed to retrieve public key: \(error.localizedDescription)")
}
}
}

private func getUserKeypair() async throws -> Keypair {
// Get the user's email to retrieve their key
let userEmail = contextToSend.sender

// Get all keypairs for the user
let keypairs = try await appContext.keyAndPassPhraseStorage.getKeypairsWithPassPhrases(email: userEmail)

// Get the first available keypair
guard let firstKeypair = keypairs.first else {
throw AppErr.general("No keypair found for \(userEmail)")
}

return firstKeypair
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension ComposeViewController: UIImagePickerControllerDelegate, UINavigationCo
reload(sections: [.attachments])
}

private func appendAttachmentIfAllowed(_ attachment: MessageAttachment) {
internal func appendAttachmentIfAllowed(_ attachment: MessageAttachment) {
let totalSize = contextToSend.attachments.map(\.size).reduce(0, +) + attachment.size
if totalSize > GeneralConstants.Global.attachmentSizeLimit {
showToast("files_picking_size_error_message".localized)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ extension ComposeViewController {
self?.handleInfoTap()
}
let attachmentButton = NavigationBarItemsView.Input(
image: UIImage(systemName: "paperclip")
image: UIImage(systemName: "paperclip"),
accessibilityId: "aid-compose-attach"
) { [weak self] in
self?.handleAttachTap()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,17 +396,24 @@ extension ComposeMessageHelper {
for attachment in message.atts {
guard let data = Data(base64Encoded: attachment.base64) else { continue }

let encryptedFile = try await core.encrypt(
file: data,
name: attachment.name,
pubKeys: message.pubKeys
)
let encryptedAttachment = SendableMsg.Attachment(
name: "\(attachment.name).pgp",
type: "application/pgp-encrypted",
base64: encryptedFile.base64EncodedString()
)
encryptedAttachments.append(encryptedAttachment)
// Skip encryption for public key attachments
if attachment.type == "application/pgp-keys" {
// Add public key attachment as-is without encryption
encryptedAttachments.append(attachment)
} else {
// Encrypt all other attachments
let encryptedFile = try await core.encrypt(
file: data,
name: attachment.name,
pubKeys: message.pubKeys
)
let encryptedAttachment = SendableMsg.Attachment(
name: "\(attachment.name).pgp",
type: "application/pgp-encrypted",
base64: encryptedFile.base64EncodedString()
)
encryptedAttachments.append(encryptedAttachment)
}
}

return encryptedAttachments
Expand Down
1 change: 1 addition & 0 deletions FlowCrypt/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ Be careful - avoid clicking links and downloading attachments, or sharing person
"files_picking_camera_input_source" = "Camera";
"files_picking_photo_library_source" = "Photo Library";
"files_picking_files_source" = "Files";
"files_picking_public_key" = "Public Key";
"files_picking_no_library_access_error_title" = "No access to library";
"files_picking_no_library_access_error_message" = "You may open Settings and give the full access to photo library";
"files_picking_no_camera_access_error_title" = "No access to camera";
Expand Down
2 changes: 1 addition & 1 deletion FlowCrypt/Resources/generated/flowcrypt-ios-prod.js.txt

Large diffs are not rendered by default.

28 changes: 24 additions & 4 deletions appium/api-mocks/apis/fes/fes-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,32 @@ export const getMockFesEndpoints = (mockConfig: MockConfig, fesConfig: FesConfig
// body is a mime-multipart string, we're doing a few smoke checks here without parsing it
if (req.method === 'POST') {
expectContains(body, '-----BEGIN PGP MESSAGE-----');
const match = String(body).match(/Content-Type: application\/json\s*\n\s*(\{.*\})/);

if (!match) {
throw new FesHttpErr('Bad request', Status.BAD_REQUEST);
// Extract JSON from request body
const bodyStr = String(body);
let messageData;

// Try different parsing strategies

// 1. Try embedded JSON after Content-Type header (original format)
const embeddedJsonMatch = bodyStr.match(/Content-Type:\s*application\/json\s*\r?\n\s*(\{.*?\})/s);

// 2. Try to find any JSON object containing associateReplyToken
const jsonObjectMatch = bodyStr.match(/\{[^{}]*"associateReplyToken"[^{}]*\}/);

if (embeddedJsonMatch) {
messageData = JSON.parse(embeddedJsonMatch[1]);
} else if (jsonObjectMatch) {
messageData = JSON.parse(jsonObjectMatch[0]);
} else {
// Last resort: try to parse the entire body as JSON
try {
messageData = JSON.parse(bodyStr);
} catch {
throw new FesHttpErr('Bad request - could not extract JSON from request body', Status.BAD_REQUEST);
}
}
const messageData = JSON.parse(match[0]);

const { associateReplyToken, to, cc, bcc } = messageData;

expect(associateReplyToken).toBe('mock-fes-reply-token');
Expand Down
18 changes: 18 additions & 0 deletions appium/tests/screenobjects/new-message.screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const SELECTORS = {
CANCEL_BUTTON: '~aid-cancel-button',
BACK_BUTTON: '~aid-back-button',
DELETE_BUTTON: '~aid-compose-delete',
ATTACH_BUTTON: '~aid-compose-attach',
ATTACH_PUBLIC_KEY_BUTTON: '~aid-attach-public-key',
SEND_BUTTON: '~aid-compose-send',
SEND_PLAIN_MESSAGE_BUTTON: '~aid-compose-send-plain',
SEND_MESSAGE_PASSWORD_BUTTON: '~aid-compose-send-message-password',
Expand Down Expand Up @@ -92,6 +94,14 @@ class NewMessageScreen extends BaseScreen {
return $(SELECTORS.DELETE_BUTTON);
}

get attachButton() {
return $(SELECTORS.ATTACH_BUTTON);
}

get attachPublicKeyButton() {
return $(SELECTORS.ATTACH_PUBLIC_KEY_BUTTON);
}

get sendButton() {
return $(SELECTORS.SEND_BUTTON);
}
Expand Down Expand Up @@ -413,6 +423,14 @@ class NewMessageScreen extends BaseScreen {
await ElementHelper.waitAndClick(await this.sendButton);
};

clickAttachButton = async () => {
await ElementHelper.waitAndClick(await this.attachButton);
};

clickAttachPublicKeyButton = async () => {
await ElementHelper.waitAndClick(await this.attachPublicKeyButton);
};

checkSendPlainMessageButtonNotPresent = async () => {
await ElementHelper.waitElementInvisible(await this.sendPlainMessageButton);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { MockApi } from 'api-mocks/mock';
import { MockApiConfig } from 'api-mocks/mock-config';
import { MockUserList } from 'api-mocks/mock-data';
import {
SplashScreen,
MailFolderScreen,
NewMessageScreen,
SetupKeyScreen,
MenuBarScreen,
EmailScreen,
} from '../../../screenobjects/all-screens';

describe('COMPOSE EMAIL: ', () => {
it('check public key attachment attach', async () => {
const mockApi = new MockApi();
const recipient = MockUserList.dmitry;
const testSubject1 = 'Test public key attachment - PGP';
const testSubject2 = 'Test public key attachment - Password';

mockApi.fesConfig = MockApiConfig.defaultEnterpriseFesConfiguration;
mockApi.ekmConfig = MockApiConfig.defaultEnterpriseEkmConfiguration;
const email = '[email protected]';
mockApi.addGoogleAccount(email);

// Set up attester to serve public key for PGP recipient
mockApi.attesterConfig = {
servedPubkeys: {
[recipient.email]: recipient.pub!,
},
};

await mockApi.withMockedApis(async () => {
await SplashScreen.mockLogin();
await SetupKeyScreen.setPassPhrase();
await MailFolderScreen.checkInboxScreen();

// Test 1: Public key attachment with regular PGP message
await MailFolderScreen.clickCreateEmail();

// Compose email
await NewMessageScreen.setAddRecipient(recipient.email);
await NewMessageScreen.setSubject(testSubject1);
await NewMessageScreen.setComposeSecurityMessage('This message includes my public key');

// Click attach button
await NewMessageScreen.clickAttachButton();

// Wait for action sheet to appear and click "Public key" using accessibility identifier
await browser.pause(1000);
await NewMessageScreen.clickAttachPublicKeyButton();

// Check that public key attachment was added
// The filename should be in format 0x{longid}.asc
await browser.pause(1000);
const attachmentLabel = await NewMessageScreen.attachmentNameLabel;
const attachmentName = await attachmentLabel.getValue();
expect(attachmentName).toMatch(/^0x[A-F0-9]{16}\.asc$/);

// Send the message
await NewMessageScreen.clickSendButton();
await browser.pause(1000);

// Go to sent folder to verify the attachment
await MenuBarScreen.clickMenuBtn();
await MenuBarScreen.clickSentButton();
await MailFolderScreen.checkSentScreen();
await MailFolderScreen.clickOnEmailBySubject(testSubject1);

// Verify attachment in sent email
// TODO: need to uncomment this line when we fix public key render issue
// https://github.com/FlowCrypt/flowcrypt-ios/issues/634
// await EmailScreen.checkPublicKeyImportView(email, recipient.pub!, false);
await EmailScreen.clickBackButton();

// Go back to inbox for test 2
await MenuBarScreen.clickMenuBtn();
await MenuBarScreen.clickInboxButton();
await MailFolderScreen.checkInboxScreen();

// Test 2: Public key attachment with password-protected message (non-PGP recipient)
await MailFolderScreen.clickCreateEmail();

// Compose email to non-PGP recipient
await NewMessageScreen.setAddRecipient('[email protected]');
await NewMessageScreen.setSubject(testSubject2);
await NewMessageScreen.setComposeSecurityMessage('This password-protected message includes my public key');

// Click attach button
await NewMessageScreen.clickAttachButton();

// Wait for action sheet to appear and click "Public key" using accessibility identifier
await browser.pause(1000);
await NewMessageScreen.clickAttachPublicKeyButton();

// Check that public key attachment was added
await browser.pause(1000);
const attachmentLabel2 = await NewMessageScreen.attachmentNameLabel;
const attachmentName2 = await attachmentLabel2.getValue();
expect(attachmentName2).toMatch(/^0x[A-F0-9]{16}\.asc$/);

// Set message password
await NewMessageScreen.clickPasswordCell();
await NewMessageScreen.setMessagePassword('abcABC1*');

// Send the password-protected message
await NewMessageScreen.clickSendButton();
await browser.pause(1000);

// Go to sent folder to verify the attachment
await MenuBarScreen.clickMenuBtn();
await MenuBarScreen.clickSentButton();
await MailFolderScreen.checkSentScreen();
await MailFolderScreen.clickOnEmailBySubject(testSubject2);

// Verify attachment in sent password-protected email
// TODO: need to uncomment this line when we fix public key render issue
// https://github.com/FlowCrypt/flowcrypt-ios/issues/634
// await EmailScreen.checkPublicKeyImportView(email, recipient.pub!, false);
});
});
});