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..