keytrans: Add reset account data field function
Some checks failed
[CI] Check Versions / Check version number consistency (push) Has been cancelled

This commit is contained in:
moiseev-signal 2026-05-16 12:07:54 -07:00 committed by GitHub
parent c41e917d4e
commit 7c8cb0c5fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 331 additions and 8 deletions

View File

@ -1,3 +1,5 @@
v0.94.1
- Add `grpc.BackupsAnonymousGetUploadForm` remote config, for both backup and backup media uploads. This is separate from the `grpc.AttachmentsGetUploadForm` config added previously, which applies to regular attachment uploads.
- keytrans: Add reset account data field functionality for all platforms.

View File

@ -4,7 +4,11 @@
//
package org.signal.libsignal.net
public abstract class KeyTransparency {
import org.signal.libsignal.internal.Native
import org.signal.libsignal.keytrans.Store
import org.signal.libsignal.protocol.ServiceId
public object KeyTransparency {
/**
* Mode of the key transparency operation.
*
@ -32,4 +36,44 @@ public abstract class KeyTransparency {
public fun isSelf(): Boolean = this is Self
}
/**
* A tag identifying an optional field of the account data.
*
* (Must be in sync with the Rust counterpart)
*/
public enum class AccountDataField(
public val value: Int,
) {
E164(0),
USERNAME_HASH(1),
}
/**
* Resets a particular field in the data associated with given ACI.
*
* Must only be called for the "self" account when either E.164 or username change is performed.
*
* Upon successful completion the data associated with the account will be updated in the store, if it
* was present to begin with, noop if it was not.
*
* @param aci An ACI of "self" account.
* @param field Account data field to be reset (E.164 or username hash)
* @param store local persistent storage for key transparency-related data.
* @throws IllegalArgumentException if the stored data cannot be decoded correctly, which means data corruption.
*/
@JvmStatic
public fun resetField(
aci: ServiceId.Aci,
field: AccountDataField,
store: Store,
) {
store.getAccountData(aci).map {
val updated = Native.KeyTransparency_ResetDataField(it, field.value)
if (updated.isEmpty()) {
throw IllegalArgumentException("failed to decode account data")
}
store.setAccountData(aci, updated)
}
}
}

View File

@ -11,6 +11,7 @@ import java.util.UUID;
import org.junit.Test;
import org.signal.libsignal.internal.NativeTesting;
import org.signal.libsignal.keytrans.KeyTransparencyException;
import org.signal.libsignal.keytrans.TestStore;
import org.signal.libsignal.keytrans.VerificationFailedException;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
@ -60,4 +61,29 @@ public class KeyTransparencyTest {
public void canBridgeChatSendError() {
assertThrows(TimeoutException.class, NativeTesting::TESTING_KeyTransChatSendError);
}
@Test
public void resetFieldThrowsOnCorruptData() {
var store = new TestStore();
store.setAccountData(TEST_ACI, new byte[] {1, 2, 3});
assertThrows(
IllegalArgumentException.class,
() -> KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store));
}
@Test
public void resetFieldIsNoopWhenDataIsMissing() {
var store = new TestStore();
KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store);
assert (store.storage.get(TEST_ACI).isEmpty());
}
@Test
public void resetFieldUpdatesStoreOnSuccess() {
var store = new TestStore();
store.setAccountData(TEST_ACI, NativeTesting.TESTING_KeyTransStoredAccountData());
assertEquals(1, store.storage.get(TEST_ACI).size());
KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store);
assertEquals(2, store.storage.get(TEST_ACI).size());
}
}

View File

@ -629,6 +629,8 @@ internal object Native {
@JvmStatic
public external fun KeyTransparency_E164SearchKey(e164: String): ByteArray
@JvmStatic
public external fun KeyTransparency_ResetDataField(accountData: ByteArray, field: Int): ByteArray
@JvmStatic
public external fun KeyTransparency_UsernameHashSearchKey(hash: ByteArray): ByteArray
@JvmStatic

View File

@ -186,6 +186,8 @@ public object NativeTesting {
@JvmStatic @Throws(Exception::class)
public external fun TESTING_KeyTransNonFatalVerificationFailure(): Unit
@JvmStatic
public external fun TESTING_KeyTransStoredAccountData(): ByteArray
@JvmStatic
public external fun TESTING_NonSuspendingBackgroundThreadRuntime_Destroy(handle: ObjectHandle): Unit
@JvmStatic
public external fun TESTING_NonSuspendingBackgroundThreadRuntime_New(): ObjectHandle

View File

@ -318,6 +318,7 @@ type NativeFunctions = {
KeyTransparency_AciSearchKey: (aci: Uint8Array<ArrayBuffer>,) => Uint8Array<ArrayBuffer>;
KeyTransparency_Check: (asyncRuntime: Wrapper<TokioAsyncContext>,environment: number,chatConnection: Wrapper<UnauthenticatedChatConnection>,aci: Uint8Array<ArrayBuffer>,aciIdentityKey: Wrapper<PublicKey>,e164: (string | null),unidentifiedAccessKey: (Uint8Array<ArrayBuffer> | null),usernameHash: (Uint8Array<ArrayBuffer> | null),accountData: (Uint8Array<ArrayBuffer> | null),lastDistinguishedTreeHead: (Uint8Array<ArrayBuffer> | null),isSelfCheck: boolean,isE164Discoverable: boolean,) => CancellablePromise<[Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>]>;
KeyTransparency_E164SearchKey: (e164: string,) => Uint8Array<ArrayBuffer>;
KeyTransparency_ResetDataField: (accountData: Uint8Array<ArrayBuffer>,field: number,) => Uint8Array<ArrayBuffer>;
KeyTransparency_UsernameHashSearchKey: (hash: Uint8Array<ArrayBuffer>,) => Uint8Array<ArrayBuffer>;
KyberKeyPair_Generate: () => KyberKeyPair;
KyberKeyPair_GetPublicKey: (keyPair: Wrapper<KyberKeyPair>,) => KyberPublicKey;
@ -634,6 +635,7 @@ type NativeFunctions = {
TESTING_KeyTransChatSendError: () => void;
TESTING_KeyTransFatalVerificationFailure: () => void;
TESTING_KeyTransNonFatalVerificationFailure: () => void;
TESTING_KeyTransStoredAccountData: () => Uint8Array<ArrayBuffer>;
TESTING_NonSuspendingBackgroundThreadRuntime_New: () => NonSuspendingBackgroundThreadRuntime;
TESTING_OtherTestingHandleType_getValue: (handle: Wrapper<OtherTestingHandleType>,) => string;
TESTING_PanicInBodyAsync: (_input: null,) => Promise<void>;
@ -898,6 +900,7 @@ const { registerErrors,
KeyTransparency_AciSearchKey,
KeyTransparency_Check,
KeyTransparency_E164SearchKey,
KeyTransparency_ResetDataField,
KeyTransparency_UsernameHashSearchKey,
KyberKeyPair_Generate,
KyberKeyPair_GetPublicKey,
@ -1214,6 +1217,7 @@ const { registerErrors,
TESTING_KeyTransChatSendError,
TESTING_KeyTransFatalVerificationFailure,
TESTING_KeyTransNonFatalVerificationFailure,
TESTING_KeyTransStoredAccountData,
TESTING_NonSuspendingBackgroundThreadRuntime_New,
TESTING_OtherTestingHandleType_getValue,
TESTING_PanicInBodyAsync,
@ -1479,6 +1483,7 @@ export { registerErrors,
KeyTransparency_AciSearchKey,
KeyTransparency_Check,
KeyTransparency_E164SearchKey,
KeyTransparency_ResetDataField,
KeyTransparency_UsernameHashSearchKey,
KyberKeyPair_Generate,
KyberKeyPair_GetPublicKey,
@ -1795,6 +1800,7 @@ export { registerErrors,
TESTING_KeyTransChatSendError,
TESTING_KeyTransFatalVerificationFailure,
TESTING_KeyTransNonFatalVerificationFailure,
TESTING_KeyTransStoredAccountData,
TESTING_NonSuspendingBackgroundThreadRuntime_New,
TESTING_OtherTestingHandleType_getValue,
TESTING_PanicInBodyAsync,

View File

@ -168,6 +168,46 @@ export interface Client {
) => Promise<void>;
}
/**
* A tag identifying an optional field of the account data.
*
* (Must be in sync with the Rust counterpart)
*/
export enum AccountDataField {
E164 = 0,
UsernameHash = 1,
}
/**
* Resets a particular field in the data associated with given ACI.
*
* Must only be called for the "self" account when either E.164 or username
* change is performed.
*
* Upon successful completion the data associated with the account will be
* updated in the store, if it was present to begin with, noop if it was not.
*
* @param aci - An ACI of "self" account.
* @param field - Account data field to be reset (E.164 or username hash).
* @param store - local persistent storage for key transparency-related data.
* @throws {TypeError} if the stored data cannot be decoded correctly, which means data corruption.
*/
export async function resetField(
aci: Aci,
field: AccountDataField,
store: Store
): Promise<void> {
const accountData = await store.getAccountData(aci);
if (accountData === null) {
return;
}
const updated = Native.KeyTransparency_ResetDataField(accountData, field);
if (updated.length === 0) {
throw new TypeError('failed to decode account data');
}
await store.setAccountData(aci, updated);
}
export class ClientImpl implements Client {
constructor(
private readonly asyncContext: TokioAsyncContext,

View File

@ -90,6 +90,33 @@ describe('KeyTransparency bridging', () => {
});
});
describe('KeyTransparency.resetField', () => {
it('throws on corrupt data', async () => {
const store = new InMemoryKtStore();
await store.setAccountData(testAci, new Uint8Array([1, 2, 3]));
await expect(
KT.resetField(testAci, KT.AccountDataField.E164, store)
).to.be.rejectedWith(TypeError);
});
it('is a noop when data is missing', async () => {
const store = new InMemoryKtStore();
await KT.resetField(testAci, KT.AccountDataField.E164, store);
expect(store.storage.get(testAci)).to.equal(undefined);
});
it('updates store on success', async () => {
const store = new InMemoryKtStore();
await store.setAccountData(
testAci,
Native.TESTING_KeyTransStoredAccountData()
);
expect(store.storage.get(testAci)).to.have.lengthOf(1);
await KT.resetField(testAci, KT.AccountDataField.E164, store);
expect(store.storage.get(testAci)).to.have.lengthOf(2);
});
});
describe('KeyTransparency network errors', () => {
it('can bridge network errors', async () => {
async function run(statusCode: number, headers: string[] = []) {

View File

@ -14,8 +14,8 @@ use libsignal_core::{Aci, E164};
use libsignal_keytrans::{AccountData, StoredAccountData};
use libsignal_net_chat::api::RequestError;
use libsignal_net_chat::api::keytrans::{
CheckMode, Error, KeyTransparencyClient, MaybePartial, SearchKey, TreeHeadWithTimestamp,
UsernameHash, check,
AccountDataField, AccountDataFieldReset as _, CheckMode, Error, KeyTransparencyClient,
MaybePartial, SearchKey, TreeHeadWithTimestamp, UsernameHash, check,
};
use libsignal_protocol::PublicKey;
use prost::{DecodeError, Message};
@ -38,6 +38,20 @@ fn KeyTransparency_UsernameHashSearchKey(hash: &[u8]) -> Vec<u8> {
UsernameHash::from_slice(hash).as_search_key()
}
#[bridge_fn]
fn KeyTransparency_ResetDataField(
account_data: Box<[u8]>,
field: AsType<AccountDataField, u8>,
) -> Vec<u8> {
// The only failure is decoding error, we'll use empty vec for that.
let decoded: Result<StoredAccountData, _> = try_decode(account_data);
let Ok(account_data) = decoded else {
log::warn!("Failed to decode stored account data");
return vec![];
};
account_data.reset(field.into_inner()).encode_to_vec()
}
#[bridge_io(TokioAsyncContext)]
#[expect(clippy::too_many_arguments)]
async fn KeyTransparency_Check(

View File

@ -3,8 +3,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
use libsignal_keytrans::{StoredAccountData, StoredMonitoringData};
use libsignal_net_chat::api::RequestError;
use libsignal_net_chat::api::keytrans::Error as KeyTransError;
use prost::Message;
use crate::*;
@ -30,3 +32,23 @@ fn TESTING_KeyTransNonFatalVerificationFailure() -> Result<(), RequestError<KeyT
fn TESTING_KeyTransChatSendError() -> Result<(), RequestError<KeyTransError>> {
Err(RequestError::Timeout)
}
#[bridge_fn]
fn TESTING_KeyTransStoredAccountData() -> Vec<u8> {
StoredAccountData {
aci: Some(StoredMonitoringData {
pos: 1,
..Default::default()
}),
e164: Some(StoredMonitoringData {
pos: 2,
..Default::default()
}),
username_hash: Some(StoredMonitoringData {
pos: 3,
..Default::default()
}),
last_tree_head: None,
}
.encode_to_vec()
}

View File

@ -19,7 +19,7 @@ use libsignal_keytrans::{
AccountData, ChatDistinguishedResponse, ChatMonitorResponse, ChatSearchResponse,
CondensedTreeSearchResponse, FullSearchResponse, FullTreeHead, KeyTransparency, LastTreeHead,
LocalStateUpdate, MonitorContext, MonitorKey, MonitorProof, MonitorRequest, MonitorResponse,
SearchContext, SearchStateUpdate, SlimSearchRequest,
SearchContext, SearchStateUpdate, SlimSearchRequest, StoredAccountData,
};
use libsignal_net::env::KeyTransConfig;
use libsignal_protocol::PublicKey;
@ -538,6 +538,20 @@ impl UnauthenticatedChatApi for KeyTransparencyClient<'_> {
}
}
pub trait AccountDataFieldReset {
fn reset(self, field: AccountDataField) -> Self;
}
impl AccountDataFieldReset for StoredAccountData {
fn reset(mut self, field: AccountDataField) -> Self {
match field {
AccountDataField::E164 => self.e164 = None,
AccountDataField::UsernameHash => self.username_hash = None,
}
self
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::cell::Cell;
@ -758,8 +772,9 @@ pub(crate) mod test_support {
#[cfg(test)]
mod test {
use assert_matches::assert_matches;
use libsignal_keytrans::StoredMonitoringData;
use prost::Message as _;
use test_case::test_case;
use test_case::{test_case, test_matrix};
use super::test_support::{
CHAT_SEARCH_RESPONSE, CHAT_SEARCH_RESPONSE_VALID_AT, KEYTRANS_CONFIG_STAGING, test_account,
@ -859,4 +874,37 @@ mod test {
assert_eq!(skip.to_vec(), missing_fields.into_iter().collect::<Vec<_>>())
);
}
#[test_matrix([AccountDataField::E164, AccountDataField::UsernameHash])]
fn reset_account_data_field(field: AccountDataField) {
let field_data = StoredMonitoringData::default();
let data = StoredAccountData {
aci: None,
e164: Some(StoredMonitoringData {
pos: 1,
..field_data.clone()
}),
username_hash: Some(StoredMonitoringData {
pos: 2,
..field_data
}),
last_tree_head: None,
};
let updated = data.clone().reset(field);
match field {
AccountDataField::E164 => {
assert!(updated.e164.is_none());
assert_matches!(
updated.username_hash,
Some(StoredMonitoringData { pos: 2, .. })
);
}
AccountDataField::UsernameHash => {
assert_matches!(updated.e164, Some(StoredMonitoringData { pos: 1, .. }));
assert!(updated.username_hash.is_none());
}
}
}
}

View File

@ -6,12 +6,16 @@
use std::collections::BTreeSet;
/// A tag identifying an optional field in [`libsignal_keytrans::AccountData`]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, displaydoc::Display)]
#[repr(u8)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, displaydoc::Display, derive_more::TryFrom,
)]
#[try_from(repr)]
pub enum AccountDataField {
/// E.164
E164,
E164 = 0,
/// Username hash
UsernameHash,
UsernameHash = 1,
}
/// This struct adds to its type parameter a (potentially empty) list of

View File

@ -63,6 +63,45 @@ public enum KeyTransparency {
}
}
/// A tag identifying an optional field of the account data.
///
/// (Must be in sync with the Rust counterpart)
public enum AccountDataField: UInt8 {
case e164 = 0
case usernameHash = 1
}
/// Resets a particular field in the data associated with given ACI.
///
/// Must only be called for the "self" account when either E.164 or username
/// change is performed.
///
/// Upon successful completion the data associated with the account will be
/// updated in the store, if it was present to begin with, noop if it was not.
///
/// - Parameters:
/// - field: Account data field to be reset (E.164 or username hash).
/// - aci: An ACI of "self" account.
/// - store: local persistent storage for key transparency-related data.
/// - Throws: ``SignalError/invalidArgument(_:)`` if the stored data cannot
/// be decoded correctly, which means data corruption.
public static func resetField(
_ field: AccountDataField,
for aci: Aci,
store: some Store
) async throws {
guard let accountData = await store.getAccountData(for: aci) else { return }
let updated = try accountData.withUnsafeBorrowedBuffer { accountDataBuffer in
try invokeFnReturningData {
signal_key_transparency_reset_data_field($0, accountDataBuffer, field.rawValue)
}
}
if updated.isEmpty {
throw SignalError.invalidArgument("failed to decode account data")
}
await store.setAccountData(updated, for: aci)
}
/// Typed API to access the key transparency subsystem using an existing
/// unauthenticated chat connection.
///

View File

@ -2150,6 +2150,8 @@ SignalFfiError *signal_key_transparency_check(SignalCPromisePairOfOwnedBufferOfc
SignalFfiError *signal_key_transparency_e164_search_key(SignalOwnedBuffer *out, const char *e164);
SignalFfiError *signal_key_transparency_reset_data_field(SignalOwnedBuffer *out, SignalBorrowedBuffer account_data, uint8_t field);
SignalFfiError *signal_key_transparency_username_hash_search_key(SignalOwnedBuffer *out, SignalBorrowedBuffer hash);
SignalFfiError *signal_kyber_key_pair_clone(SignalMutPointerKyberKeyPair *new_obj, SignalConstPointerKyberKeyPair obj);

View File

@ -389,6 +389,8 @@ SignalFfiError *signal_testing_key_trans_fatal_verification_failure(void);
SignalFfiError *signal_testing_key_trans_non_fatal_verification_failure(void);
SignalFfiError *signal_testing_key_trans_stored_account_data(SignalOwnedBuffer *out);
SignalFfiError *signal_testing_other_testing_handle_type_get_value(const char **out, SignalConstPointerOtherTestingHandleType handle);
SignalFfiError *signal_testing_panic_in_body_async(const void *_input);

View File

@ -113,6 +113,32 @@ final class KeyTransparencyTests: TestCaseBase {
XCTAssertEqual(1, store.distinguishedTreeHeads.count)
}
func testResetFieldThrowsOnCorruptData() async throws {
let store = TestStore()
await store.setAccountData(Data([1, 2, 3]), for: self.testAccount.aci)
do {
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTFail("should have failed")
} catch SignalError.invalidArgument(_) {
} catch {
XCTFail("unexpected exception thrown: \(error)")
}
}
func testResetFieldIsNoopWhenDataIsMissing() async throws {
let store = TestStore()
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTAssertNil(store.accountData[self.testAccount.aci])
}
// These testing endpoints aren't generated in device builds, to save on code size.
#if !os(iOS) || targetEnvironment(simulator)
func testNonFatalErrorBridging() throws {
@ -145,6 +171,23 @@ final class KeyTransparencyTests: TestCaseBase {
}
}
func testResetFieldUpdatesStoreOnSuccess() async throws {
let store = TestStore()
let storedAccountData = failOnError {
try invokeFnReturningData {
signal_testing_key_trans_stored_account_data($0)
}
}
await store.setAccountData(storedAccountData, for: self.testAccount.aci)
XCTAssertEqual(1, store.accountData[self.testAccount.aci]!.count)
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTAssertEqual(2, store.accountData[self.testAccount.aci]!.count)
}
func customNetworkErrorTestImpl(status: UInt16, headers: [String: String] = [:]) async throws {
let tokio = TokioAsyncContext()
let (chat, remote) = UnauthenticatedChatConnection.fakeConnect(