Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 16 additions & 4 deletions Vault/Sources/VaultFeed/Encryption/KillphraseDigester.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import FoundationExtensions

/// Computes and verifies one-way killphrase digests for a vault.
///
/// Digests are `HMAC-SHA256(K, salt || phrase)` where `K` is a 256-bit key
/// Digests are `HMAC-SHA256(K, salt || normalize(phrase))` where
/// `normalize(...)` trims surrounding whitespace and `K` is a 256-bit key
/// that lives in the device keychain (see `KillphraseKeyStore`). The same
/// `K` is reused across every item; per-item randomness comes from `salt`,
/// which is regenerated on every set.
Expand All @@ -25,25 +26,36 @@ public struct KillphraseDigester: KillphraseMatcher, Sendable {
/// Produce a digest for the given plaintext phrase, using a fresh random salt.
public func makeDigest(phrase: String) -> KillphraseDigest {
let salt = Data.random(count: Self.saltLength)
let digest = computeDigest(phrase: phrase, salt: salt)
let digest = computeDigest(phrase: Self.normalize(phrase), salt: salt)
return KillphraseDigest(salt: salt, digest: digest)
}

/// Returns `true` iff `HMAC(K, salt || query)` equals `digest`.
/// Returns `true` iff `HMAC(K, salt || normalize(query))` equals `digest`.
///
/// Uses CryptoKit's `isValidAuthenticationCode` which performs a
/// constant-time comparison. Callers must not branch on this result in
/// any externally observable way beyond performing the deletion itself.
public func matches(query: String, salt: Data, digest: Data) -> Bool {
var message = salt
message.append(Data(query.utf8))
message.append(Data(Self.normalize(query).utf8))
return HMAC<SHA256>.isValidAuthenticationCode(
digest,
authenticating: message,
using: key,
)
}

/// Both sides of the comparison must apply the same normalization or
/// the HMAC will not match: digests have always been created from
/// trimmed phrases, but the live search query arrives untrimmed, so a
/// trailing space from the keyboard would silently stop a killphrase
/// from firing. Trim only — no canonical/case fold, because existing
/// digests were computed without one and killphrases are intentionally
/// exact-match otherwise.
static func normalize(_ phrase: String) -> String {
phrase.trimmingCharacters(in: .whitespacesAndNewlines)
}

private func computeDigest(phrase: String, salt: Data) -> Data {
var message = salt
message.append(Data(phrase.utf8))
Expand Down
5 changes: 4 additions & 1 deletion Vault/Sources/VaultFeed/Storage/VaultDataModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,12 @@ extension VaultDataModel {
// only available when the vault is unlocked. If we don't have
// one yet (vault still locked, key load failed), skip the
// delete pass — the search itself remains functional.
// Match on the same sanitized text the search predicate uses,
// so a phrase the user can see matching in the feed also fires
// the killphrase (digests are built from trimmed phrases).
let didDeleteKillphraseItems: Bool = if let digester = killphraseDigester {
await vaultKillphraseDeleter
.deleteItems(matchingKillphrase: itemsSearchQuery, using: digester)
.deleteItems(matchingKillphrase: itemsSanitizedQuery ?? itemsSearchQuery, using: digester)
} else {
false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ struct KillphraseDigesterTests {

#expect(sut.matches(query: "", salt: digest.salt, digest: digest.digest) == false)
}

@Test
func matches_trimsWhitespaceFromQuery() {
let sut = makeSUT()
let digest = sut.makeDigest(phrase: "phrase")

// The search bar delivers the query untrimmed; a trailing space
// from the keyboard must not stop the killphrase firing.
#expect(sut.matches(query: "phrase ", salt: digest.salt, digest: digest.digest))
#expect(sut.matches(query: " phrase\n", salt: digest.salt, digest: digest.digest))
}

@Test
func makeDigest_trimsWhitespaceFromPhrase() {
let sut = makeSUT()
let digest = sut.makeDigest(phrase: " phrase ")

#expect(sut.matches(query: "phrase", salt: digest.salt, digest: digest.digest))
}

@Test
func matches_doesNotTrimInteriorWhitespace() {
let sut = makeSUT()
let digest = sut.makeDigest(phrase: "two words")

#expect(sut.matches(query: "twowords", salt: digest.salt, digest: digest.digest) == false)
#expect(sut.matches(query: "two words", salt: digest.salt, digest: digest.digest))
}
}

extension KillphraseDigesterTests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,25 @@ final class PersistedLocalVaultStoreTests {
try await assertStoreContains(exactlyItems: [item2, item3])
}

@Test
func deleteItemsMatchingKillphrase_matchesQueryWithSurroundingWhitespace() async throws {
let item1 = uniqueVaultItem(killphrase: "phrase")
let item2 = uniqueVaultItem(killphrase: "other")
let payload = VaultApplicationPayload(
userDescription: "Hello world",
items: [item1, item2],
tags: [],
)
try await sut.importAndOverrideVault(payload: payload)

// The search bar delivers untrimmed text; a trailing space must
// not stop the killphrase firing.
let didDelete = await sut.deleteItems(matchingKillphrase: "phrase ", using: testDigester)

#expect(didDelete == true)
try await assertStoreContains(exactlyItems: [item2])
}

@Test
func deleteItemsMatchingKillphrase_doesNotDeleteEmptyKillphraseItems() async throws {
let item1 = uniqueVaultItem(killphrase: nil)
Expand Down
62 changes: 62 additions & 0 deletions Vault/Tests/VaultFeedTests/Storage/VaultDataModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,68 @@ final class VaultDataModelTests {
}
}

@Test
func reloadItems_matchesKillphraseWhenSearchQueryHasTrailingWhitespace() async {
let store = VaultStoreStub()
let killphraseDeleter = VaultStoreKillphraseDeleterMock()
let keyStore = KillphraseKeyStoreMock()
keyStore.loadOrCreateHandler = {
(try? KeyData<32>(data: Data(repeating: 0xAA, count: 32))) ?? .zero()
}
let sut = makeSUT(
vaultStore: store,
vaultKillphraseDeleter: killphraseDeleter,
killphraseKeyStore: keyStore,
)
await sut.setup()
// Untrimmed query, as delivered by the search bar. The deleter
// must receive the same sanitized text the search predicate uses,
// since digests are built from trimmed phrases.
sut.itemsSearchQuery = " hello world \n"

await confirmation("Delete called", expectedCount: 1) { confirmDelete in
killphraseDeleter.deleteItemsHandler = { query, _ in
#expect(query == "hello world")
confirmDelete()
return false
}

await sut.reloadItems()
}
}

@Test
func reloadItems_doesNotInvokeKillphraseDeleterWhenDigesterNeverLoaded() async {
let killphraseDeleter = VaultStoreKillphraseDeleterMock()
let sut = makeSUT(vaultKillphraseDeleter: killphraseDeleter)
// No setup(): the digester is never loaded, matching the
// vault-still-locked state. The delete pass must be skipped.
sut.itemsSearchQuery = "hello world"

await sut.reloadItems()

#expect(killphraseDeleter.deleteItemsCallCount == 0)
}

@Test
func reloadItems_doesNotInvokeKillphraseDeleterWhenKeyStoreFails() async {
let killphraseDeleter = VaultStoreKillphraseDeleterMock()
let keyStore = KillphraseKeyStoreMock()
keyStore.loadOrCreateHandler = { throw TestError() }
let sut = makeSUT(
vaultKillphraseDeleter: killphraseDeleter,
killphraseKeyStore: keyStore,
)
// Setup runs, but the key load fails, so the digester stays nil
// and killphrase deletion must remain a no-op.
await sut.setup()
sut.itemsSearchQuery = "hello world"

await sut.reloadItems()

#expect(killphraseDeleter.deleteItemsCallCount == 0)
}

@Test
func reloadItems_syncsAutofillAndNotifiesWhenKillphraseDeletesItems() async {
let store = VaultStoreStub()
Expand Down