diff --git a/java/client/src/main/java/org/signal/libsignal/messagebackup/AccountEntropyPool.java b/java/client/src/main/java/org/signal/libsignal/messagebackup/AccountEntropyPool.java index 87b4d6ea1..933fa9aa5 100644 --- a/java/client/src/main/java/org/signal/libsignal/messagebackup/AccountEntropyPool.java +++ b/java/client/src/main/java/org/signal/libsignal/messagebackup/AccountEntropyPool.java @@ -9,8 +9,42 @@ import org.signal.libsignal.internal.Native; +/** + * The randomly-generated user-memorized entropy used to derive the backup key, with other possible + * future uses. + */ public class AccountEntropyPool { + /** + * Generate a new entropy pool and return the canonical string representation. + * + *

This pool contains log_2(36^64) = ~330 bits of cryptographic quality randomness. + * + * @return A 64 character string containing randomly chosen digits from [a-z0-9]. + */ public static String generate() { - return filterExceptions(() -> (Native.AccountEntropyPool_Generate())); + return filterExceptions(() -> Native.AccountEntropyPool_Generate()); + } + + /** + * Derives an SVR key from the given account entropy pool. + * + *

{@code accountEntropyPool} must be a **validated** account entropy pool; passing an + * arbitrary string here is considered a programmer error. + */ + public static byte[] deriveSvrKey(String accountEntropyPool) { + return Native.AccountEntropyPool_DeriveSvrKey(accountEntropyPool); + } + + /** + * Derives a backup key from the given account entropy pool. + * + *

{@code accountEntropyPool} must be a **validated** account entropy pool; passing an + * arbitrary string here is considered a programmer error. + * + * @see BackupKey#generateRandom + */ + public static BackupKey deriveBackupKey(String accountEntropyPool) { + return filterExceptions( + () -> new BackupKey(Native.AccountEntropyPool_DeriveBackupKey(accountEntropyPool))); } } diff --git a/java/client/src/main/java/org/signal/libsignal/messagebackup/BackupKey.java b/java/client/src/main/java/org/signal/libsignal/messagebackup/BackupKey.java new file mode 100644 index 000000000..ad1781ea0 --- /dev/null +++ b/java/client/src/main/java/org/signal/libsignal/messagebackup/BackupKey.java @@ -0,0 +1,75 @@ +// +// Copyright 2024 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +package org.signal.libsignal.messagebackup; + +import static org.signal.libsignal.internal.FilterExceptions.filterExceptions; + +import java.security.SecureRandom; +import org.signal.libsignal.internal.Native; +import org.signal.libsignal.protocol.ServiceId.Aci; +import org.signal.libsignal.protocol.ecc.ECPrivateKey; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.internal.ByteArray; + +/** + * The randomly-generated user-memorized entropy used to derive the backup key, with other possible + * future uses. + */ +public class BackupKey extends ByteArray { + public static final int SIZE = 32; + + public BackupKey(byte[] contents) throws InvalidInputException { + super(contents, SIZE); + } + + /** + * Generates a random backup key. + * + *

Useful for tests and for the media root backup key, which is not derived from anything else. + * + * @see AccountEntropyPool#deriveBackupKey + */ + public static BackupKey generateRandom() { + SecureRandom secureRandom = new SecureRandom(); + byte[] bytes = new byte[BackupKey.SIZE]; + secureRandom.nextBytes(bytes); + return filterExceptions(() -> new BackupKey(bytes)); + } + + /** Derives the backup ID to use given the current device's ACI. */ + public byte[] deriveBackupId(Aci aci) { + return Native.BackupKey_DeriveBackupId( + this.getInternalContentsForJNI(), aci.toServiceIdFixedWidthBinary()); + } + + /** Derives the backup EC key to use given the current device's ACI. */ + public ECPrivateKey deriveEcKey(Aci aci) { + return new ECPrivateKey( + Native.BackupKey_DeriveEcKey( + this.getInternalContentsForJNI(), aci.toServiceIdFixedWidthBinary())); + } + + /** Derives the AES key used for encrypted fields in local backup metadata. */ + public byte[] deriveLocalBackupMetadataKey() { + return Native.BackupKey_DeriveLocalBackupMetadataKey(this.getInternalContentsForJNI()); + } + + /** Derives the ID for uploading media with the name {@code mediaName}. */ + public byte[] deriveMediaId(String mediaName) { + return Native.BackupKey_DeriveMediaId(this.getInternalContentsForJNI(), mediaName); + } + + /** + * Derives the composite encryption key for uploading media with the given ID. + * + *

This is a concatenation of an HMAC key (32 bytes) and an AES-CBC key (also 32 bytes). + * + *

Throws {@link IllegalArgumentException} if the media ID is invalid. + */ + public byte[] deriveMediaEncryptionKey(byte[] mediaId) { + return Native.BackupKey_DeriveMediaEncryptionKey(this.getInternalContentsForJNI(), mediaId); + } +} diff --git a/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackupKey.java b/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackupKey.java index 0041fdc6a..3d6709b30 100644 --- a/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackupKey.java +++ b/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackupKey.java @@ -5,6 +5,8 @@ package org.signal.libsignal.messagebackup; +import static org.signal.libsignal.internal.FilterExceptions.filterExceptions; + import org.signal.libsignal.internal.Native; import org.signal.libsignal.internal.NativeHandleGuard; import org.signal.libsignal.protocol.ServiceId.Aci; @@ -40,8 +42,18 @@ public MessageBackupKey(String accountEntropy, Aci aci) { *

This uses AccountEntropyPool-based key derivation rules; it cannot be used to read a backup * created from a master key. */ + public MessageBackupKey(BackupKey backupKey, byte[] backupId) { + this.nativeHandle = + Native.MessageBackupKey_FromBackupKeyAndBackupId( + backupKey.getInternalContentsForJNI(), backupId); + } + + /** + * @deprecated Use the overload that takes a strongly-typed BackupKey instead. + */ + @Deprecated public MessageBackupKey(byte[] backupKey, byte[] backupId) { - this.nativeHandle = Native.MessageBackupKey_FromBackupKeyAndBackupId(backupKey, backupId); + this(filterExceptions(() -> new BackupKey(backupKey)), backupId); } @Override diff --git a/java/client/src/test/java/org/signal/libsignal/messagebackup/AccountEntropyPoolTest.java b/java/client/src/test/java/org/signal/libsignal/messagebackup/AccountEntropyPoolTest.java index 2c28e06e2..651654b74 100644 --- a/java/client/src/test/java/org/signal/libsignal/messagebackup/AccountEntropyPoolTest.java +++ b/java/client/src/test/java/org/signal/libsignal/messagebackup/AccountEntropyPoolTest.java @@ -5,11 +5,18 @@ package org.signal.libsignal.messagebackup; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.UUID; import org.junit.Test; +import org.signal.libsignal.protocol.ServiceId.Aci; public class AccountEntropyPoolTest { @Test @@ -23,4 +30,44 @@ public void accountEntropyStringMeetsSpecifications() { assertTrue("Duplicate pool generated: " + pool, generatedEntropyPools.add(pool)); } } + + @Test + public void testKeyDerivations() throws Exception { + var pool = AccountEntropyPool.generate(); + + var svrKey = AccountEntropyPool.deriveSvrKey(pool); + assertEquals(32, svrKey.length); + + var backupKey = AccountEntropyPool.deriveBackupKey(pool); + assertEquals(32, backupKey.serialize().length); + + var randomKey = BackupKey.generateRandom(); + assertNotEquals(backupKey, randomKey); + + var aci = new Aci(new UUID(0x1111111111111111L, 0x1111111111111111L)); + var otherAci = new Aci(UUID.randomUUID()); + + var backupId = backupKey.deriveBackupId(aci); + assertEquals(16, backupId.length); + assertFalse(Arrays.equals(backupId, randomKey.deriveBackupId(aci))); + assertFalse(Arrays.equals(backupId, backupKey.deriveBackupId(otherAci))); + + var ecKey = backupKey.deriveEcKey(aci); + assertFalse(Arrays.equals(ecKey.serialize(), randomKey.deriveEcKey(aci).serialize())); + assertFalse(Arrays.equals(ecKey.serialize(), backupKey.deriveEcKey(otherAci).serialize())); + + var localMetadataKey = backupKey.deriveLocalBackupMetadataKey(); + assertEquals(32, localMetadataKey.length); + + var mediaId = backupKey.deriveMediaId("example.jpg"); + assertEquals(15, mediaId.length); + + var mediaKey = backupKey.deriveMediaEncryptionKey(mediaId); + assertEquals(32 + 32, mediaKey.length); + + assertThrows( + "invalid media ID", + IllegalArgumentException.class, + () -> backupKey.deriveMediaEncryptionKey(new byte[1])); + } } diff --git a/java/client/src/test/java/org/signal/libsignal/messagebackup/MessageBackupValidationTest.java b/java/client/src/test/java/org/signal/libsignal/messagebackup/MessageBackupValidationTest.java index acdd914b8..e36af804d 100644 --- a/java/client/src/test/java/org/signal/libsignal/messagebackup/MessageBackupValidationTest.java +++ b/java/client/src/test/java/org/signal/libsignal/messagebackup/MessageBackupValidationTest.java @@ -48,7 +48,11 @@ static MessageBackupKey makeMessageBackupKeyFromBackupId() { "20241024_SIGNAL_BACKUP_ID:".getBytes(StandardCharsets.UTF_8), aci.toServiceIdBinary()), 16); - return new MessageBackupKey(backupKey, backupId); + try { + return new MessageBackupKey(new BackupKey(backupKey), backupId); + } catch (Exception e) { + throw new AssertionError(e); + } } static final String VALID_BACKUP_RESOURCE_NAME = "encryptedbackup.binproto.encrypted"; diff --git a/java/shared/java/org/signal/libsignal/internal/Native.java b/java/shared/java/org/signal/libsignal/internal/Native.java index 56c168cbd..422c0a844 100644 --- a/java/shared/java/org/signal/libsignal/internal/Native.java +++ b/java/shared/java/org/signal/libsignal/internal/Native.java @@ -115,6 +115,8 @@ private Native() {} */ public static native void keepAlive(Object obj); + public static native byte[] AccountEntropyPool_DeriveBackupKey(String accountEntropy); + public static native byte[] AccountEntropyPool_DeriveSvrKey(String accountEntropy); public static native String AccountEntropyPool_Generate(); public static native void Aes256Ctr32_Destroy(long handle); @@ -171,6 +173,12 @@ private Native() {} public static native int BackupAuthCredential_GetType(byte[] credentialBytes); public static native byte[] BackupAuthCredential_PresentDeterministic(byte[] credentialBytes, byte[] serverParamsBytes, byte[] randomness) throws Exception; + public static native byte[] BackupKey_DeriveBackupId(byte[] backupKey, byte[] aci); + public static native long BackupKey_DeriveEcKey(byte[] backupKey, byte[] aci); + public static native byte[] BackupKey_DeriveLocalBackupMetadataKey(byte[] backupKey); + public static native byte[] BackupKey_DeriveMediaEncryptionKey(byte[] backupKey, byte[] mediaId); + public static native byte[] BackupKey_DeriveMediaId(byte[] backupKey, String mediaName); + public static native void CallLinkAuthCredentialPresentation_CheckValidContents(byte[] presentationBytes) throws Exception; public static native byte[] CallLinkAuthCredentialPresentation_GetUserId(byte[] presentationBytes); public static native void CallLinkAuthCredentialPresentation_Verify(byte[] presentationBytes, long now, byte[] serverParamsBytes, byte[] callLinkParamsBytes) throws Exception; diff --git a/node/Native.d.ts b/node/Native.d.ts index 9ac81cc0c..9cdd66871 100644 --- a/node/Native.d.ts +++ b/node/Native.d.ts @@ -143,6 +143,8 @@ type Serialized = Buffer; export function registerErrors(errorsModule: Record): void; export const enum LogLevel { Error = 1, Warn, Info, Debug, Trace } +export function AccountEntropyPool_DeriveBackupKey(accountEntropy: string): Buffer; +export function AccountEntropyPool_DeriveSvrKey(accountEntropy: string): Buffer; export function AccountEntropyPool_Generate(): string; export function Aes256GcmSiv_Decrypt(aesGcmSiv: Wrapper, ctext: Buffer, nonce: Buffer, associatedData: Buffer): Buffer; export function Aes256GcmSiv_Encrypt(aesGcmSivObj: Wrapper, ptext: Buffer, nonce: Buffer, associatedData: Buffer): Buffer; @@ -170,6 +172,11 @@ export function BackupAuthCredential_GetBackupId(credentialBytes: Buffer): Buffe export function BackupAuthCredential_GetBackupLevel(credentialBytes: Buffer): number; export function BackupAuthCredential_GetType(credentialBytes: Buffer): number; export function BackupAuthCredential_PresentDeterministic(credentialBytes: Buffer, serverParamsBytes: Buffer, randomness: Buffer): Buffer; +export function BackupKey_DeriveBackupId(backupKey: Buffer, aci: Buffer): Buffer; +export function BackupKey_DeriveEcKey(backupKey: Buffer, aci: Buffer): PrivateKey; +export function BackupKey_DeriveLocalBackupMetadataKey(backupKey: Buffer): Buffer; +export function BackupKey_DeriveMediaEncryptionKey(backupKey: Buffer, mediaId: Buffer): Buffer; +export function BackupKey_DeriveMediaId(backupKey: Buffer, mediaName: string): Buffer; export function CallLinkAuthCredentialPresentation_CheckValidContents(presentationBytes: Buffer): void; export function CallLinkAuthCredentialPresentation_GetUserId(presentationBytes: Buffer): Serialized; export function CallLinkAuthCredentialPresentation_Verify(presentationBytes: Buffer, now: Timestamp, serverParamsBytes: Buffer, callLinkParamsBytes: Buffer): void; diff --git a/node/ts/AccountKeys.ts b/node/ts/AccountKeys.ts index b9e77b169..2b1a9c1fb 100644 --- a/node/ts/AccountKeys.ts +++ b/node/ts/AccountKeys.ts @@ -11,7 +11,11 @@ * @module AccountKeys */ +import * as crypto from 'node:crypto'; import * as Native from '../Native'; +import ByteArray from './zkgroup/internal/ByteArray'; +import { Aci } from './Address'; +import { PrivateKey } from './EcKeys'; /** * The randomly-generated user-memorized entropy used to derive the backup key, @@ -21,7 +25,7 @@ import * as Native from '../Native'; */ export class AccountEntropyPool { /** - * Randomly generates an Account Entropy Pool and returns the cannonical string + * Randomly generates an Account Entropy Pool and returns the canonical string * representation of that pool. * * @returns cryptographically random 64 character string of characters a-z, 0-9 @@ -29,4 +33,87 @@ export class AccountEntropyPool { public static generate(): string { return Native.AccountEntropyPool_Generate(); } + + /** + * Derives an SVR key from the given account entropy pool. + * + * `accountEntropyPool` must be a **validated** account entropy pool; + * passing an arbitrary string here is considered a programmer error. + */ + public static deriveSvrKey(accountEntropyPool: string): Buffer { + return Native.AccountEntropyPool_DeriveSvrKey(accountEntropyPool); + } + + /** + * Derives a backup key from the given account entropy pool. + * + * `accountEntropyPool` must be a **validated** account entropy pool; + * passing an arbitrary string here is considered a programmer error. + * + * @see {@link BackupKey.generateRandom} + */ + public static deriveBackupKey(accountEntropyPool: string): BackupKey { + return new BackupKey( + Native.AccountEntropyPool_DeriveBackupKey(accountEntropyPool) + ); + } +} + +/** A key used for many aspects of backups. */ +export class BackupKey extends ByteArray { + private readonly __type?: never; + static SIZE = 32; + + constructor(contents: Buffer) { + super(contents, BackupKey.checkLength(BackupKey.SIZE)); + } + + /** + * Generates a random backup key. + * + * Useful for tests and for the media root backup key, which is not derived from anything else. + * + * @see {@link AccountEntropyPool.deriveBackupKey} + */ + public static generateRandom(): BackupKey { + const bytes = crypto.randomBytes(BackupKey.SIZE); + return new BackupKey(bytes); + } + + /** Derives the backup ID to use given the current device's ACI. */ + public deriveBackupId(aci: Aci): Buffer { + return Native.BackupKey_DeriveBackupId( + this.contents, + aci.getServiceIdFixedWidthBinary() + ); + } + + /** Derives the backup EC key to use given the current device's ACI. */ + public deriveEcKey(aci: Aci): PrivateKey { + return PrivateKey._fromNativeHandle( + Native.BackupKey_DeriveEcKey( + this.contents, + aci.getServiceIdFixedWidthBinary() + ) + ); + } + + /** Derives the AES key used for encrypted fields in local backup metadata. */ + public deriveLocalBackupMetadataKey(): Buffer { + return Native.BackupKey_DeriveLocalBackupMetadataKey(this.contents); + } + + /** Derives the ID for uploading media with the name `mediaName`. */ + public deriveMediaId(mediaName: string): Buffer { + return Native.BackupKey_DeriveMediaId(this.contents, mediaName); + } + + /** + * Derives the composite encryption key for uploading media with the given ID. + * + * This is a concatenation of an HMAC key (32 bytes) and an AES-CBC key (also 32 bytes). + */ + public deriveMediaEncryptionKey(mediaId: Buffer): Buffer { + return Native.BackupKey_DeriveMediaEncryptionKey(this.contents, mediaId); + } } diff --git a/node/ts/MessageBackup.ts b/node/ts/MessageBackup.ts index 7e963e224..082a6a853 100644 --- a/node/ts/MessageBackup.ts +++ b/node/ts/MessageBackup.ts @@ -10,6 +10,7 @@ */ import * as Native from '../Native'; +import { BackupKey } from './AccountKeys'; import { Aci } from './Address'; import { InputStream } from './io'; @@ -52,7 +53,7 @@ export type MessageBackupKeyInput = Readonly< aci: Aci; } | { - backupKey: Buffer; + backupKey: BackupKey | Buffer; backupId: Buffer; } >; @@ -100,7 +101,11 @@ export class MessageBackupKey { aci.getServiceIdFixedWidthBinary() ); } else { - const { backupKey, backupId } = inputOrMasterKeyBytes; + const { backupId } = inputOrMasterKeyBytes; + let { backupKey } = inputOrMasterKeyBytes; + if (backupKey instanceof BackupKey) { + backupKey = backupKey.contents; + } this._nativeHandle = Native.MessageBackupKey_FromBackupKeyAndBackupId( backupKey, backupId diff --git a/node/ts/test/AccountKeysTest.ts b/node/ts/test/AccountKeysTest.ts index 9ce2d082e..8c8623b60 100644 --- a/node/ts/test/AccountKeysTest.ts +++ b/node/ts/test/AccountKeysTest.ts @@ -4,40 +4,90 @@ // import { assert } from 'chai'; +import * as uuid from 'uuid'; import * as AccountKeys from '../AccountKeys'; import * as util from './util'; +import { Aci } from '../Address'; util.initLogger(); -describe('Pin', () => { - describe('AccountEntropyPool', () => { - describe('generate()', () => { - const NUM_TEST_ITERATIONS = 100; - - it('returns a unique string each time', () => { - const generatedEntropyPools = new Set(); - - for (let i = 0; i < NUM_TEST_ITERATIONS; i++) { - const pool = AccountKeys.AccountEntropyPool.generate(); - assert.isFalse( - generatedEntropyPools.has(pool), - `${pool} was generated twice` - ); - generatedEntropyPools.add(pool); - } - }); - - it('returns only strings consisting of 64 characters a-z and 0-9', () => { - const validCharactersRegex = /^[a-z0-9]{64}$/; - for (let i = 0; i < NUM_TEST_ITERATIONS; i++) { - const pool = AccountKeys.AccountEntropyPool.generate(); - assert.match( - pool, - validCharactersRegex, - 'Pool must be 64 characters consisting of only a-z and 0-9' - ); - } - }); +describe('AccountEntropyPool', () => { + describe('generate()', () => { + const NUM_TEST_ITERATIONS = 100; + + it('returns a unique string each time', () => { + const generatedEntropyPools = new Set(); + + for (let i = 0; i < NUM_TEST_ITERATIONS; i++) { + const pool = AccountKeys.AccountEntropyPool.generate(); + assert.isFalse( + generatedEntropyPools.has(pool), + `${pool} was generated twice` + ); + generatedEntropyPools.add(pool); + } }); + + it('returns only strings consisting of 64 characters a-z and 0-9', () => { + const validCharactersRegex = /^[a-z0-9]{64}$/; + for (let i = 0; i < NUM_TEST_ITERATIONS; i++) { + const pool = AccountKeys.AccountEntropyPool.generate(); + assert.match( + pool, + validCharactersRegex, + 'Pool must be 64 characters consisting of only a-z and 0-9' + ); + } + }); + }); + + it('can derive SVR keys', () => { + const pool = AccountKeys.AccountEntropyPool.generate(); + const svrKey = AccountKeys.AccountEntropyPool.deriveSvrKey(pool); + assert.equal(32, svrKey.length); + }); +}); + +describe('BackupKey', () => { + const aci = Aci.fromUuidBytes(new Uint8Array(16).fill(0x11)); + + it('can be derived or randomly generated', () => { + const pool = AccountKeys.AccountEntropyPool.generate(); + const backupKey = AccountKeys.AccountEntropyPool.deriveBackupKey(pool); + assert.equal(32, backupKey.serialize().length); + + const randomKey = AccountKeys.BackupKey.generateRandom(); + assert.isFalse(backupKey.serialize().equals(randomKey.serialize())); + }); + + it('can generate derived keys', () => { + const pool = AccountKeys.AccountEntropyPool.generate(); + const backupKey = AccountKeys.AccountEntropyPool.deriveBackupKey(pool); + const randomKey = AccountKeys.BackupKey.generateRandom(); + const otherAci = Aci.fromUuid(uuid.v4()); + + const backupId = backupKey.deriveBackupId(aci); + assert.equal(16, backupId.length); + assert.isFalse(backupId.equals(randomKey.deriveBackupId(aci))); + assert.isFalse(backupId.equals(backupKey.deriveBackupId(otherAci))); + + const ecKey = backupKey.deriveEcKey(aci); + assert.isFalse( + ecKey.serialize().equals(randomKey.deriveEcKey(aci).serialize()) + ); + assert.isFalse( + ecKey.serialize().equals(backupKey.deriveEcKey(otherAci).serialize()) + ); + + const localMetadataKey = backupKey.deriveLocalBackupMetadataKey(); + assert.equal(32, localMetadataKey.length); + + const mediaId = backupKey.deriveMediaId('example.jpg'); + assert.equal(15, mediaId.length); + + const mediaKey = backupKey.deriveMediaEncryptionKey(mediaId); + assert.equal(32 + 32, mediaKey.length); + + assert.throws(() => backupKey.deriveMediaEncryptionKey(Buffer.of(0))); }); }); diff --git a/node/ts/test/MessageBackupTest.ts b/node/ts/test/MessageBackupTest.ts index 0896712cc..e53f97e1b 100644 --- a/node/ts/test/MessageBackupTest.ts +++ b/node/ts/test/MessageBackupTest.ts @@ -11,6 +11,7 @@ import { Uint8ArrayInputStream, ErrorInputStream } from './ioutil'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { hkdf, LogLevel } from '..'; +import { BackupKey } from '../AccountKeys'; util.initLogger(LogLevel.Trace); @@ -51,7 +52,7 @@ describe('MessageBackup', () => { null ); const testKeyFromBackupId = new MessageBackup.MessageBackupKey({ - backupKey, + backupKey: new BackupKey(backupKey), backupId, }); diff --git a/rust/bridge/ffi/cbindgen.toml b/rust/bridge/ffi/cbindgen.toml index 05c235b46..3bd5b95cf 100644 --- a/rust/bridge/ffi/cbindgen.toml +++ b/rust/bridge/ffi/cbindgen.toml @@ -89,7 +89,12 @@ include = [ "signal-media", "zkgroup", ] -extra_bindings = ["libsignal-bridge", "libsignal-bridge-types", "zkgroup"] +extra_bindings = [ + "libsignal-account-keys", + "libsignal-bridge", + "libsignal-bridge-types", + "zkgroup", +] [parse.expand] crates = ["libsignal-ffi", "libsignal-bridge"] diff --git a/rust/bridge/shared/src/account_keys.rs b/rust/bridge/shared/src/account_keys.rs index 2d2e32a8c..42a3a987f 100644 --- a/rust/bridge/shared/src/account_keys.rs +++ b/rust/bridge/shared/src/account_keys.rs @@ -3,10 +3,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // +use std::str::FromStr as _; + use ::attest::svr2::lookup_groupid; -use ::libsignal_account_keys::{local_pin_hash, verify_local_pin_hash, PinHash, Result}; -use libsignal_account_keys::{AccountEntropyPool, Error}; +use libsignal_account_keys::*; use libsignal_bridge_macros::*; +use libsignal_core::Aci; +use libsignal_protocol::PrivateKey; use crate::support::*; use crate::*; @@ -57,3 +60,61 @@ pub fn Pin_VerifyLocalHash(encoded_hash: String, pin: &[u8]) -> Result { pub fn AccountEntropyPool_Generate() -> String { AccountEntropyPool::generate(&mut rand::thread_rng()).to_string() } + +#[bridge_fn] +pub fn AccountEntropyPool_DeriveSvrKey(account_entropy: String) -> [u8; SVR_KEY_LEN] { + let entropy = AccountEntropyPool::from_str(&account_entropy) + .expect("should only pass validated entropy pool here"); + entropy.derive_svr_key() +} + +#[bridge_fn] +pub fn AccountEntropyPool_DeriveBackupKey(account_entropy: String) -> [u8; BACKUP_KEY_LEN] { + let entropy = AccountEntropyPool::from_str(&account_entropy) + .expect("should only pass validated entropy pool here"); + let backup_key = BackupKey::derive_from_account_entropy_pool(&entropy); + backup_key.0 +} + +#[bridge_fn] +pub fn BackupKey_DeriveBackupId(backup_key: &[u8; BACKUP_KEY_LEN], aci: Aci) -> [u8; 16] { + // The explicit type forces the latest version of the key derivation scheme. + let backup_key: BackupKey = BackupKey(*backup_key); + backup_key.derive_backup_id(&aci).0 +} + +#[bridge_fn] +pub fn BackupKey_DeriveEcKey(backup_key: &[u8; BACKUP_KEY_LEN], aci: Aci) -> PrivateKey { + // The explicit type forces the latest version of the key derivation scheme. + let backup_key: BackupKey = BackupKey(*backup_key); + backup_key.derive_ec_key(&aci) +} + +#[bridge_fn] +pub fn BackupKey_DeriveLocalBackupMetadataKey( + backup_key: &[u8; BACKUP_KEY_LEN], +) -> [u8; LOCAL_BACKUP_METADATA_KEY_LEN] { + // The explicit type forces the latest version of the key derivation scheme. + let backup_key: BackupKey = BackupKey(*backup_key); + backup_key.derive_local_backup_metadata_key() +} + +#[bridge_fn] +pub fn BackupKey_DeriveMediaId( + backup_key: &[u8; BACKUP_KEY_LEN], + media_name: String, +) -> [u8; MEDIA_ID_LEN] { + // The explicit type forces the latest version of the key derivation scheme. + let backup_key: BackupKey = BackupKey(*backup_key); + backup_key.derive_media_id(&media_name) +} + +#[bridge_fn] +pub fn BackupKey_DeriveMediaEncryptionKey( + backup_key: &[u8; BACKUP_KEY_LEN], + media_id: &[u8; MEDIA_ID_LEN], +) -> [u8; MEDIA_ENCRYPTION_KEY_LEN] { + // The explicit type forces the latest version of the key derivation scheme. + let backup_key: BackupKey = BackupKey(*backup_key); + backup_key.derive_media_encryption_key_data(media_id) +} diff --git a/swift/Sources/LibSignalClient/AccountKeys.swift b/swift/Sources/LibSignalClient/AccountKeys.swift index b320202d7..9b4395d6a 100644 --- a/swift/Sources/LibSignalClient/AccountKeys.swift +++ b/swift/Sources/LibSignalClient/AccountKeys.swift @@ -117,7 +117,7 @@ public class PinHash: NativeHandleOwner, @unchecked Sendable { /// The randomly-generated user-memorized entropy used to derive the backup key, with other possible future uses. public enum AccountEntropyPool { - /// Generate a new entropy pool and return the cannonical string representation. + /// Generate a new entropy pool and return the canonical string representation. /// /// This pool contains log_2(36^64) = ~330 bits of cryptographic quality randomness. /// @@ -129,4 +129,109 @@ public enum AccountEntropyPool { } } } + + /// Derives an SVR key from the given account entropy pool. + /// + /// `accountEntropyPool` must be a **validated** account entropy pool; + /// passing an arbitrary String here is considered a programmer error. + public static func deriveSvrKey(_ accountEntropyPool: String) throws -> [UInt8] { + try invokeFnReturningFixedLengthArray { + signal_account_entropy_pool_derive_svr_key($0, accountEntropyPool) + } + } + + /// Derives a backup key from the given account entropy pool. + /// + /// `accountEntropyPool` must be a **validated** account entropy pool; + /// passing an arbitrary String here is considered a programmer error. + /// + /// - SeeAlso: ``BackupKey/generateRandom()`` + public static func deriveBackupKey(_ accountEntropyPool: String) throws -> BackupKey { + try invokeFnReturningSerialized { + signal_account_entropy_pool_derive_backup_key($0, accountEntropyPool) + } + } +} + +/// A key used for many aspects of backups. +public class BackupKey: ByteArray, @unchecked Sendable { + public static let SIZE = 32 + + /// Throws if `contents` is not ``SIZE`` (32) bytes. + public required init(contents: [UInt8]) throws { + try super.init(newContents: contents, expectedLength: Self.SIZE) + } + + /// Generates a random backup key. + /// + /// Useful for tests and for the media root backup key, which is not derived from anything else. + /// + /// - SeeAlso: ``AccountEntropyPool/deriveBackupKey(_:)`` + public static func generateRandom() -> BackupKey { + failOnError { + var bytes: [UInt8] = Array(repeating: 0, count: Self.SIZE) + try bytes.withUnsafeMutableBytes { try fillRandom($0) } + return try BackupKey(contents: bytes) + } + } + + /// Derives the backup ID to use given the current device's ACI. + public func deriveBackupId(aci: Aci) -> [UInt8] { + failOnError { + try withUnsafePointerToSerialized { backupKey in + try aci.withPointerToFixedWidthBinary { aci in + try invokeFnReturningFixedLengthArray { + signal_backup_key_derive_backup_id($0, backupKey, aci) + } + } + } + } + } + + /// Derives the backup EC key to use given the current device's ACI. + public func deriveEcKey(aci: Aci) -> PrivateKey { + failOnError { + try withUnsafePointerToSerialized { backupKey in + try aci.withPointerToFixedWidthBinary { aci in + try invokeFnReturningNativeHandle { + signal_backup_key_derive_ec_key($0, backupKey, aci) + } + } + } + } + } + + /// Derives the AES key used for encrypted fields in local backup metadata. + public func deriveLocalBackupMetadataKey() -> [UInt8] { + failOnError { + try withUnsafePointerToSerialized { backupKey in + try invokeFnReturningFixedLengthArray { + signal_backup_key_derive_local_backup_metadata_key($0, backupKey) + } + } + } + } + + /// Derives the ID for uploading media with the name `mediaName`. + public func deriveMediaId(_ mediaName: String) throws -> [UInt8] { + try withUnsafePointerToSerialized { backupKey in + try invokeFnReturningFixedLengthArray { + signal_backup_key_derive_media_id($0, backupKey, mediaName) + } + } + } + + /// Derives the composite encryption key for uploading media with the given ID. + /// + /// This is a concatenation of an HMAC key (32 bytes) and an AES-CBC key (also 32 bytes). + public func deriveMediaEncryptionKey(_ mediaId: [UInt8]) throws -> [UInt8] { + let mediaId = try ByteArray(newContents: mediaId, expectedLength: 15) + return try withUnsafePointerToSerialized { backupKey in + try mediaId.withUnsafePointerToSerialized { mediaId in + try invokeFnReturningFixedLengthArray { + signal_backup_key_derive_media_encryption_key($0, backupKey, mediaId) + } + } + } + } } diff --git a/swift/Sources/LibSignalClient/MessageBackup.swift b/swift/Sources/LibSignalClient/MessageBackup.swift index cd69b850f..002ca2ea0 100644 --- a/swift/Sources/LibSignalClient/MessageBackup.swift +++ b/swift/Sources/LibSignalClient/MessageBackup.swift @@ -39,8 +39,7 @@ public class MessageBackupKey: NativeHandleOwner, @unchecked Sendable { /// /// This uses AccountEntropyPool-based key derivation rules; /// it cannot be used to read a backup created from a master key. - public convenience init(backupKey: [UInt8], backupId: [UInt8]) throws { - let backupKey = try ByteArray(newContents: backupKey, expectedLength: 32) + public convenience init(backupKey: BackupKey, backupId: [UInt8]) throws { let backupId = try ByteArray(newContents: backupId, expectedLength: 16) let handle = try backupKey.withUnsafePointerToSerialized { backupKey in try backupId.withUnsafePointerToSerialized { backupId in @@ -52,6 +51,12 @@ public class MessageBackupKey: NativeHandleOwner, @unchecked Sendable { self.init(owned: handle!) } + @available(*, deprecated, message: "Use the overload that takes a strongly-typed BackupKey instead") + public convenience init(backupKey: [UInt8], backupId: [UInt8]) throws { + let backupKey = try BackupKey(contents: backupKey) + try self.init(backupKey: backupKey, backupId: backupId) + } + internal required init(owned handle: OpaquePointer) { super.init(owned: handle) } diff --git a/swift/Sources/SignalFfi/signal_ffi.h b/swift/Sources/SignalFfi/signal_ffi.h index 2ad75d44e..7425d5914 100644 --- a/swift/Sources/SignalFfi/signal_ffi.h +++ b/swift/Sources/SignalFfi/signal_ffi.h @@ -15,6 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only #include #include +#define SignalSVR_KEY_LEN 32 + +#define SignalBACKUP_KEY_LEN 32 + +#define SignalLOCAL_BACKUP_METADATA_KEY_LEN 32 + +#define SignalMEDIA_ID_LEN 15 + +#define SignalMEDIA_ENCRYPTION_KEY_LEN (32 + 32) + #define SignalBackupKey_MASTER_KEY_LEN SignalSVR_KEY_LEN #define SignalBackupId_LEN 16 @@ -1567,6 +1577,20 @@ SignalFfiError *signal_pin_verify_local_hash(bool *out, const char *encoded_hash SignalFfiError *signal_account_entropy_pool_generate(const char **out); +SignalFfiError *signal_account_entropy_pool_derive_svr_key(uint8_t (*out)[SignalSVR_KEY_LEN], const char *account_entropy); + +SignalFfiError *signal_account_entropy_pool_derive_backup_key(uint8_t (*out)[SignalBACKUP_KEY_LEN], const char *account_entropy); + +SignalFfiError *signal_backup_key_derive_backup_id(uint8_t (*out)[16], const uint8_t (*backup_key)[SignalBACKUP_KEY_LEN], const SignalServiceIdFixedWidthBinaryBytes *aci); + +SignalFfiError *signal_backup_key_derive_ec_key(SignalPrivateKey **out, const uint8_t (*backup_key)[SignalBACKUP_KEY_LEN], const SignalServiceIdFixedWidthBinaryBytes *aci); + +SignalFfiError *signal_backup_key_derive_local_backup_metadata_key(uint8_t (*out)[SignalLOCAL_BACKUP_METADATA_KEY_LEN], const uint8_t (*backup_key)[SignalBACKUP_KEY_LEN]); + +SignalFfiError *signal_backup_key_derive_media_id(uint8_t (*out)[SignalMEDIA_ID_LEN], const uint8_t (*backup_key)[SignalBACKUP_KEY_LEN], const char *media_name); + +SignalFfiError *signal_backup_key_derive_media_encryption_key(uint8_t (*out)[SignalMEDIA_ENCRYPTION_KEY_LEN], const uint8_t (*backup_key)[SignalBACKUP_KEY_LEN], const uint8_t (*media_id)[SignalMEDIA_ID_LEN]); + SignalFfiError *signal_svr2_client_new(SignalSgxClientState **out, SignalBorrowedBuffer mrenclave, SignalBorrowedBuffer attestation_msg, uint64_t current_timestamp); SignalFfiError *signal_incremental_mac_destroy(SignalIncrementalMac *p); diff --git a/swift/Tests/LibSignalClientTests/AccountEntropyTests.swift b/swift/Tests/LibSignalClientTests/AccountEntropyTests.swift new file mode 100644 index 000000000..d7c615b89 --- /dev/null +++ b/swift/Tests/LibSignalClientTests/AccountEntropyTests.swift @@ -0,0 +1,71 @@ +// +// Copyright 2024 Signal Messenger, LLC. +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import LibSignalClient +import XCTest + +class AccountEntropyTests: TestCaseBase { + func testAccountEntropyPool() { + let numTestIterations = 100 + var generatedEntropyPools = Set() + // generate() must return exactly 64 characters consisting only of a-z and 0-9. + let validCharacters = Set("abcdefghijklmnopqrstuvwxyz0123456789") + + for _ in 0..() - // generate() must return exactly 64 characters consisting only of a-z and 0-9. - let validCharacters = Set("abcdefghijklmnopqrstuvwxyz0123456789") - - for _ in 0..