Skip to content

Conversation

fedemkr
Copy link
Member

@fedemkr fedemkr commented Aug 21, 2025

🎟️ Tracking

PM-21862
PM-22864

📔 Objective

Implement passing AEAD (Authenticated Encryption with Associated Data) keys to the SDK crypto initialization and storing them.

Note

SDK Crypto Re-initialization from the sync profile response keys can't be done yet given we lack the crypto method necessary data to do so, therefore it's going to be done in a future PR when the SDK updates the API to allow a different way to update such keys.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@fedemkr fedemkr requested a review from quexten August 21, 2025 20:49
Copy link
Contributor

github-actions bot commented Aug 21, 2025

Logo
Checkmarx One – Scan Summary & Detailse88fbe74-fade-4f2f-95cc-70bc40a325f2

Great job! No new security vulnerabilities introduced in this pull request

Copy link
Contributor

@quexten quexten left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

Some nits, and a question around the keychain. I don't think we need to store the signing key and the security state in the keychain. The security state is signed, by the signing key, and the signing key is encrypted by the userkey, and they are safe to store in the same way as the user private key, which - as far as I can tell - is not in the keychain.

typealias SignedPublicKey = String

/// A public key in base64 encoded SPKI-DER.
typealias UnsignedPublicKey = [UInt8]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should this be string? I'm not exactly sure how the conversion from response model to bytes works in swift.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should work but we could just try it when we have the server part deployed somewhere I can target and check the response. I also could try locally if it's urgent to know.
Additionally, if you have a serialized json response I could use, I can quickly check this in a test.

userId: account.profile.userId,
kdfParams: account.kdf.sdkKdf,
email: account.profile.email,
privateKey: encryptionKeys.encryptedPrivateKey,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking): This should probably already use the accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey if available. These keys are the same, but I imagine the local state will in the future drop encryptionKeys.encryptedPrivateKey?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already using that as when initializing the AccountEncryptionKeys I set the encryptedPrivateKey to the one in accountKeys if present, and to privateKey otherwise.
Would we want to maintain them separate for some reason or can I still reuse the same property for both private keys depending on the context?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in the medium term we can drop encryptionKeys.encryptedPrivateKey entirely, since it's the same as accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we can definitely drop in a future PR given that now we'd need to do a migration in order to move the stored encryptionKeys.encryptedPrivateKey to accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey and we wouldn't gain much now.

@@ -131,6 +131,7 @@ extension DefaultKeyConnectorService: KeyConnectorService {

try await stateService.setAccountEncryptionKeys(
AccountEncryptionKeys(
accountKeys: nil,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (not-blocking): I think we can already store privateKeyResponseModel here by constructing it from the existing private key. That would allow us to drop the encryptedPrivateKey property quicker.

@@ -8,6 +9,9 @@ enum KeychainItem: Equatable {
/// The keychain item for a user's access token.
case accessToken(userId: String)

/// The keychain item for a user's account security state.
case accountSecurityState(userId: String)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccountSecurityState and UserSigningKey don't need to be protected by the keychain. As far as I understand, we don't store the userPrivateKey here either. They should be stored the same way as userPrivateKey.

Copy link

codecov bot commented Aug 27, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.65%. Comparing base (f131eb5) to head (269fcc2).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1873   +/-   ##
=======================================
  Coverage   88.64%   88.65%           
=======================================
  Files         810      811    +1     
  Lines       51124    51158   +34     
=======================================
+ Hits        45321    45353   +32     
- Misses       5803     5805    +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@fedemkr fedemkr marked this pull request as ready for review August 27, 2025 20:51
@fedemkr fedemkr requested review from matt-livefront and a team as code owners August 27, 2025 20:51
@fedemkr fedemkr requested a review from quexten August 27, 2025 20:51
Copy link
Contributor

@quexten quexten left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look reasonable. My only question is around the type of public key in the state here. I'm not sure if this is intended.

"accountKeys": {
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": "WRAPPED_PRIVATE_KEY",
"publicKey": [1, 2, 3, 4, 5],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question / note: This is a base64 encoded string on the server https://github.com/bitwarden/server/blob/8a39481fec87848e19cdd92d620d3e23b97901cb/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs#L9. Is it intended to be an array of bytes here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch! You're right, I saw an object in the clients repo with that type and I mistakenly applied it here directly missing it was being converted from a string in the response. Fixed and removed the UnsignedPublicKey typealias as it's no longer use.

@fedemkr fedemkr requested a review from quexten September 1, 2025 20:25
Comment on lines +4 to +11
/// The wrapped private key.
let wrappedPrivateKey: WrappedPrivateKey

/// The public key.
let publicKey: String

/// The signed public key.
let signedPublicKey: SignedPublicKey?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ Do we want to alphabetize these similar to other models?

Comment on lines +824 to +825
let accountKeys = try? await stateService.getAccountEncryptionKeys()
accountPrivateKeys = accountKeys?.accountKeys
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Do you think this would end up throwing in the expected use cases? Would it better to use try instead of try? here? If so, we could also move getting the encryption keys outside if the if since we do that in both cases.

@@ -604,6 +604,7 @@ extension DefaultAuthRepository: AuthRepository {

try await stateService.setAccountEncryptionKeys(
AccountEncryptionKeys(
accountKeys: nil,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Is it possible to construct a PrivateKeysResponseModel here with the keys we have or does the server need to do that? If I'm understanding correctly, I think this is similar to #1873 (comment).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants