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
1 change: 0 additions & 1 deletion packages/hypergraph/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"@noble/secp256k1": "^2.2.3",
"@xmtp/xmtp-js": "^13.0.4",
"@xstate/store": "^2.6.2",
"bs58check": "^4.0.0",
"effect": "^3.12.4",
Expand Down
62 changes: 56 additions & 6 deletions packages/hypergraph/src/identity/identity-encryption.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { gcm } from '@noble/ciphers/aes';
import { randomBytes } from '@noble/ciphers/webcrypto';
import { Ciphertext, decrypt, encrypt } from '@xmtp/xmtp-js';
import { hkdf } from '@noble/hashes/hkdf';
import { sha256 } from '@noble/hashes/sha256';
import type { Hex } from 'viem';
import { verifyMessage } from 'viem';

Expand All @@ -8,7 +10,56 @@ import type { IdentityKeys, Signer } from './types.js';

// Adapted from the XMTP approach to encrypt keys
// See: https://github.com/xmtp/xmtp-js/blob/8d6e5a65813902926baac8150a648587acbaad92/sdks/js-sdk/src/keystore/providers/NetworkKeyManager.ts#L79-L116
// (We use their encrypt/decrypt functions, safer than re-implementing them).
// (We reimplement their encrypt/decrypt functions using noble).

const hkdfDeriveKey = (secret: Uint8Array, salt: Uint8Array): Uint8Array => {
return hkdf(sha256, secret, salt, '', 32);
};

// This implements the same encryption as https://github.com/xmtp/xmtp-js/blob/336471de4ea95416ad0f4f9850d3f12bb0a13f1e/sdks/js-sdk/src/encryption/encryption.ts#L18
// But using @noble/ciphers instead of the WebCrypto API.
// The XMTP code was audited by Certik: https://skynet.certik.com/projects/xmtp
//
// Worth noting that GCM nonce collision would break the encryption,
// and 12 bytes is not a lot. So this function should not be used to encrypt
// a large number of messages with the same secret. In our case it should be okay
// as each secret is only used to encrypt a single identity. If we need
// something more secure for a larger number of messages we should use a
// different encryption scheme, e.g. XAES-256-GCM, see https://words.filippo.io/dispatches/xaes-256-gcm/
const encrypt = (msg: Uint8Array, secret: Uint8Array): string => {
const hkdfSalt = randomBytes(32);
const gcmNonce = randomBytes(12);
const derivedKey = hkdfDeriveKey(secret, hkdfSalt);

const aes = gcm(derivedKey, gcmNonce);

const ciphertext = aes.encrypt(msg);

// TODO: Use Effect Schema and better serialization?
const ciphertextJson = JSON.stringify({
aes256GcmHkdfSha256: {
payload: bytesToHex(ciphertext),
hkdfSalt: bytesToHex(hkdfSalt),
gcmNonce: bytesToHex(gcmNonce),
},
});
return bytesToHex(new TextEncoder().encode(ciphertextJson));
};

// This implements the same decryption as https://github.com/xmtp/xmtp-js/blob/336471de4ea95416ad0f4f9850d3f12bb0a13f1e/sdks/js-sdk/src/encryption/encryption.ts#L41
// But using @noble/ciphers instead of the WebCrypto API
// The XMTP code was audited by Certik: https://skynet.certik.com/projects/xmtp
const decrypt = (ciphertext: string, secret: Uint8Array): Uint8Array => {
const ciphertextJson = new TextDecoder().decode(hexToBytes(ciphertext));
const { aes256GcmHkdfSha256 } = JSON.parse(ciphertextJson);
const hkdfSalt = hexToBytes(aes256GcmHkdfSha256.hkdfSalt);
const gcmNonce = hexToBytes(aes256GcmHkdfSha256.gcmNonce);
const derivedKey = hkdfDeriveKey(secret, hkdfSalt);

const aes = gcm(derivedKey, gcmNonce);

return aes.decrypt(hexToBytes(aes256GcmHkdfSha256.payload));
};

const signatureMessage = (nonce: Uint8Array): string => {
return `The Graph: sign to encrypt/decrypt identity keys.\nNonce: ${bytesToHex(nonce)}\n`;
Expand Down Expand Up @@ -42,7 +93,7 @@ export const encryptIdentity = async (
keys.signaturePrivateKey,
].join('\n');
const keysMsg = new TextEncoder().encode(keysTxt);
const ciphertext = bytesToHex((await encrypt(keysMsg, secretKey)).toBytes());
const ciphertext = encrypt(keysMsg, secretKey);
return { ciphertext, nonce: bytesToHex(nonce) };
};

Expand All @@ -66,9 +117,8 @@ export const decryptIdentity = async (
}
const secretKey = hexToBytes(signature);
let keysMsg: Uint8Array;
const ciphertextObj = Ciphertext.fromBytes(hexToBytes(ciphertext));
try {
keysMsg = await decrypt(ciphertextObj, secretKey);
keysMsg = await decrypt(ciphertext, secretKey);
} catch (e) {
// See https://github.com/xmtp/xmtp-js/blob/8d6e5a65813902926baac8150a648587acbaad92/sdks/js-sdk/src/keystore/providers/NetworkKeyManager.ts#L142-L146
if (secretKey.length !== 65) {
Expand All @@ -82,7 +132,7 @@ export const decryptIdentity = async (
} else {
newSecret = new Uint8Array([...newSecret, lastByte - 27]);
}
keysMsg = await decrypt(ciphertextObj, newSecret);
keysMsg = await decrypt(ciphertext, newSecret);
}
const keysTxt = new TextDecoder().decode(keysMsg);
const [encryptionPublicKey, encryptionPrivateKey, signaturePublicKey, signaturePrivateKey] = keysTxt.split('\n');
Expand Down
2 changes: 1 addition & 1 deletion packages/hypergraph/test/identity/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('createIdentity', () => {
});

describe('identity encryption', () => {
it.skip('should encrypt and decrypt an identity using a wallet', async () => {
it('should encrypt and decrypt an identity using a wallet', async () => {
// generate a random private key to simulate a user wallet
const account = privateKeyToAccount(bytesToHex(randomBytes(32)) as Hex);
const signer = accountSigner(account);
Expand Down
Loading
Loading