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
81 changes: 10 additions & 71 deletions apps/connect/src/routes/authenticate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { createWalletClient, custom } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const CHAIN = import.meta.env.VITE_HYPERGRAPH_CHAIN === 'geogenesis' ? Connect.GEOGENESIS : Connect.GEO_TESTNET;
const API_URL =
import.meta.env.VITE_HYPERGRAPH_CHAIN === 'geogenesis'
? `${Graph.MAINNET_API_ORIGIN}/graphql`
: `${Graph.TESTNET_API_ORIGIN}/graphql`;

type AuthenticateSearch = {
data: unknown;
Expand Down Expand Up @@ -135,19 +139,14 @@ function AuthenticateComponent() {
const accountAddress = useSelector(StoreConnect.store, (state) => state.context.accountAddress);
const keys = useSelector(StoreConnect.store, (state) => state.context.keys);

const { signMessage } = usePrivy();
const { wallets } = useWallets();
const embeddedWallet = wallets.find((wallet) => wallet.walletClientType === 'privy') || wallets[0];

const state = useSelector(componentStore, (state) => state.context);
const [selectedPrivateSpaces, setSelectedPrivateSpaces] = useState<Set<string>>(new Set());

const { isPending: privateSpacesPending, error: privateSpacesError, data: privateSpacesData } = usePrivateSpaces();
const {
isPending: publicSpacesPending,
error: publicSpacesError,
data: publicSpacesData,
} = usePublicSpaces(`${Graph.TESTNET_API_ORIGIN}/graphql`);
const { data: publicSpacesData } = usePublicSpaces(API_URL);

useEffect(() => {
const run = async () => {
Expand Down Expand Up @@ -334,21 +333,6 @@ function AuthenticateComponent() {
transport: custom(privyProvider),
});

const signer: Identity.Signer = {
getAddress: async () => {
const [address] = await walletClient.getAddresses();
return address;
},
signMessage: async (message: string) => {
if (embeddedWallet.walletClientType === 'privy') {
const { signature } = await signMessage({ message });
return signature;
}
const [address] = await walletClient.getAddresses();
return await walletClient.signMessage({ account: address, message });
},
};

const newAppIdentity = Connect.createAppIdentity();

console.log('creating smart session');
Expand Down Expand Up @@ -388,25 +372,13 @@ function AuthenticateComponent() {
rpcUrl: import.meta.env.VITE_HYPERGRAPH_RPC_URL,
});

const appIdentityKeys = {
encryptionPrivateKey: newAppIdentity.encryptionPrivateKey,
encryptionPublicKey: newAppIdentity.encryptionPublicKey,
signaturePrivateKey: newAppIdentity.signaturePrivateKey,
signaturePublicKey: newAppIdentity.signaturePublicKey,
};
console.log('encrypting app identity');
const { ciphertext, nonce } = await Connect.encryptAppIdentity(
signer,
newAppIdentity.address,
newAppIdentity.addressPrivateKey,
permissionId,
appIdentityKeys,
);
const { ciphertext } = await Connect.encryptAppIdentity({ ...newAppIdentity, permissionId }, keys);
console.log('proving ownership');
const { accountProof, keyProof } = await Identity.proveIdentityOwnership(
smartAccountClient,
accountAddress,
appIdentityKeys,
newAppIdentity,
);

const message: Messages.RequestConnectCreateAppIdentity = {
Expand All @@ -416,7 +388,6 @@ function AuthenticateComponent() {
signaturePublicKey: keys.signaturePublicKey,
encryptionPublicKey: keys.encryptionPublicKey,
ciphertext,
nonce,
accountProof,
keyProof,
};
Expand Down Expand Up @@ -459,48 +430,16 @@ function AuthenticateComponent() {
};

const decryptAppIdentityAndRedirect = async () => {
if (!state.appIdentityResponse) {
if (!state.appIdentityResponse || !keys) {
return;
}

const privyProvider = await embeddedWallet.getEthereumProvider();
const walletClient = createWalletClient({
account: embeddedWallet.address as `0x${string}`,
chain: CHAIN,
transport: custom(privyProvider),
});

const signer: Identity.Signer = {
getAddress: async () => {
const [address] = await walletClient.getAddresses();
return address;
},
signMessage: async (message: string) => {
if (embeddedWallet.walletClientType === 'privy') {
const { signature } = await signMessage({ message });
return signature;
}
const [address] = await walletClient.getAddresses();
return await walletClient.signMessage({ account: address, message });
},
};

const decryptedIdentity = await Connect.decryptAppIdentity(
signer,
state.appIdentityResponse.ciphertext,
state.appIdentityResponse.nonce,
);
const decryptedIdentity = await Connect.decryptAppIdentity(state.appIdentityResponse.ciphertext, keys);
await encryptSpacesAndRedirect({
accountAddress: state.appIdentityResponse.accountAddress,
appIdentity: {
address: decryptedIdentity.address,
addressPrivateKey: decryptedIdentity.addressPrivateKey,
...decryptedIdentity,
accountAddress: state.appIdentityResponse.accountAddress,
permissionId: decryptedIdentity.permissionId,
encryptionPrivateKey: decryptedIdentity.encryptionPrivateKey,
signaturePrivateKey: decryptedIdentity.signaturePrivateKey,
encryptionPublicKey: decryptedIdentity.encryptionPublicKey,
signaturePublicKey: decryptedIdentity.signaturePublicKey,
sessionToken: state.appIdentityResponse.sessionToken,
sessionTokenExpires: new Date(state.appIdentityResponse.sessionTokenExpires),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
Warnings:

- You are about to drop the column `nonce` on the `AppIdentity` table. All the data in the column will be lost.

*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_AppIdentity" (
"address" TEXT NOT NULL PRIMARY KEY,
"ciphertext" TEXT NOT NULL,
"signaturePublicKey" TEXT NOT NULL,
"encryptionPublicKey" TEXT NOT NULL,
"accountProof" TEXT NOT NULL,
"keyProof" TEXT NOT NULL,
"accountAddress" TEXT NOT NULL,
"appId" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"sessionTokenExpires" DATETIME NOT NULL,
CONSTRAINT "AppIdentity_accountAddress_fkey" FOREIGN KEY ("accountAddress") REFERENCES "Account" ("address") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_AppIdentity" ("accountAddress", "accountProof", "address", "appId", "ciphertext", "encryptionPublicKey", "keyProof", "sessionToken", "sessionTokenExpires", "signaturePublicKey") SELECT "accountAddress", "accountProof", "address", "appId", "ciphertext", "encryptionPublicKey", "keyProof", "sessionToken", "sessionTokenExpires", "signaturePublicKey" FROM "AppIdentity";
DROP TABLE "AppIdentity";
ALTER TABLE "new_AppIdentity" RENAME TO "AppIdentity";
CREATE INDEX "AppIdentity_sessionToken_idx" ON "AppIdentity"("sessionToken");
CREATE UNIQUE INDEX "AppIdentity_accountAddress_appId_key" ON "AppIdentity"("accountAddress", "appId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
2 changes: 0 additions & 2 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ model Account {
model AppIdentity {
address String @id
ciphertext String
nonce String
signaturePublicKey String
encryptionPublicKey String
accountProof String
Expand All @@ -129,7 +128,6 @@ model AppIdentity {
sessionTokenExpires DateTime

@@unique([accountAddress, appId])
@@unique([accountAddress, nonce])
@@index([sessionToken])
}

Expand Down
2 changes: 0 additions & 2 deletions apps/server/src/handlers/create-app-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export const createAppIdentity = async ({
address,
appId,
ciphertext,
nonce,
signaturePublicKey,
encryptionPublicKey,
accountProof,
Expand All @@ -43,7 +42,6 @@ export const createAppIdentity = async ({
accountAddress,
appId,
ciphertext,
nonce,
signaturePublicKey,
encryptionPublicKey,
accountProof,
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,6 @@ app.post('/connect/app-identity', async (req, res) => {
appId: message.appId,
address: message.address,
ciphertext: message.ciphertext,
nonce: message.nonce,
signaturePublicKey: message.signaturePublicKey,
encryptionPublicKey: message.encryptionPublicKey,
accountProof: message.accountProof,
Expand Down
99 changes: 32 additions & 67 deletions packages/hypergraph/src/connect/identity-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import { sha256 } from '@noble/hashes/sha256';
import type { Hex } from 'viem';
import { verifyMessage } from 'viem';

import { cryptoBoxSeal, cryptoBoxSealOpen } from '@serenity-kit/noble-sodium';
import { bytesToHex, canonicalize, hexToBytes } from '../utils/index.js';
import type { IdentityKeys, PrivateAppIdentity, Signer } from './types.js';

export type AppIdentityForEncryption = Omit<
PrivateAppIdentity,
'sessionToken' | 'sessionTokenExpires' | 'accountAddress'
>;

// 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 reimplement their encrypt/decrypt functions using noble).
Expand Down Expand Up @@ -134,95 +140,54 @@ export const decryptIdentity = async (signer: Signer, ciphertext: string, nonce:
};

export const encryptAppIdentity = async (
signer: Signer,
appIdentityAddress: string,
appIdentityAddressPrivateKey: string,
permissionId: string,
appIdentity: AppIdentityForEncryption,
keys: IdentityKeys,
): Promise<{ ciphertext: string; nonce: string }> => {
const nonce = randomBytes(32);
const message = signatureMessage(nonce);
const signature = (await signer.signMessage(message)) as Hex;

// Check that the signature is valid
const valid = await verifyMessage({
address: (await signer.getAddress()) as Hex,
message,
signature,
});
if (!valid) {
throw new Error('Invalid signature');
}
const secretKey = hexToBytes(signature);
): Promise<{ ciphertext: string }> => {
// We use a simple plaintext encoding:
// Hex keys separated by newlines
const keysTxt = [
keys.encryptionPublicKey,
keys.encryptionPrivateKey,
keys.signaturePublicKey,
keys.signaturePrivateKey,
appIdentityAddress,
appIdentityAddressPrivateKey,
permissionId,
appIdentity.encryptionPublicKey,
appIdentity.encryptionPrivateKey,
appIdentity.signaturePublicKey,
appIdentity.signaturePrivateKey,
appIdentity.address,
appIdentity.addressPrivateKey,
appIdentity.permissionId,
].join('\n');
const keysMsg = new TextEncoder().encode(keysTxt);
const ciphertext = encrypt(keysMsg, secretKey);
return { ciphertext, nonce: bytesToHex(nonce) };
const ciphertext = bytesToHex(
cryptoBoxSeal({
message: keysMsg,
publicKey: hexToBytes(keys.encryptionPublicKey),
}),
);
return { ciphertext };
};

export const decryptAppIdentity = async (
signer: Signer,
ciphertext: string,
nonce: string,
): Promise<Omit<PrivateAppIdentity, 'sessionToken' | 'sessionTokenExpires' | 'accountAddress'>> => {
const message = signatureMessage(hexToBytes(nonce));
const signature = (await signer.signMessage(message)) as Hex;

// Check that the signature is valid
const valid = await verifyMessage({
address: (await signer.getAddress()) as Hex,
message,
signature,
export const decryptAppIdentity = async (ciphertext: string, keys: IdentityKeys): Promise<AppIdentityForEncryption> => {
const ciphertextBytes = hexToBytes(ciphertext);
const keysMsg = cryptoBoxSealOpen({
ciphertext: ciphertextBytes,
privateKey: hexToBytes(keys.encryptionPrivateKey),
publicKey: hexToBytes(keys.encryptionPublicKey),
});
Comment on lines +169 to 173
Copy link

Copilot AI Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function should handle potential decryption failures and provide a meaningful error message. The cryptoBoxSealOpen function can throw if decryption fails, but there's no error handling or context about what went wrong.

Suggested change
const keysMsg = cryptoBoxSealOpen({
ciphertext: ciphertextBytes,
privateKey: hexToBytes(keys.encryptionPrivateKey),
publicKey: hexToBytes(keys.encryptionPublicKey),
});
let keysMsg: Uint8Array;
try {
keysMsg = cryptoBoxSealOpen({
ciphertext: ciphertextBytes,
privateKey: hexToBytes(keys.encryptionPrivateKey),
publicKey: hexToBytes(keys.encryptionPublicKey),
});
} catch (error) {
throw new Error('Decryption failed. Ensure the ciphertext and keys are correct.');
}

Copilot uses AI. Check for mistakes.
Comment on lines +169 to 173
Copy link

Copilot AI Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should wrap cryptoBoxSealOpen in a try-catch block to provide better error handling. If decryption fails, the error should indicate that the app identity could not be decrypted rather than exposing low-level crypto errors.

Suggested change
const keysMsg = cryptoBoxSealOpen({
ciphertext: ciphertextBytes,
privateKey: hexToBytes(keys.encryptionPrivateKey),
publicKey: hexToBytes(keys.encryptionPublicKey),
});
let keysMsg;
try {
keysMsg = cryptoBoxSealOpen({
ciphertext: ciphertextBytes,
privateKey: hexToBytes(keys.encryptionPrivateKey),
publicKey: hexToBytes(keys.encryptionPublicKey),
});
} catch (error) {
throw new Error("Failed to decrypt app identity. Please check the provided keys and ciphertext.");
}

Copilot uses AI. Check for mistakes.
if (!valid) {
throw new Error('Invalid signature');
}
const secretKey = hexToBytes(signature);
let keysMsg: Uint8Array;
try {
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) {
throw new Error('Expected 65 bytes before trying a different recovery byte');
}
// Try the other version of recovery byte, either +27 or -27
const lastByte = secretKey[secretKey.length - 1];
let newSecret = secretKey.slice(0, secretKey.length - 1);
if (lastByte < 27) {
newSecret = new Uint8Array([...newSecret, lastByte + 27]);
} else {
newSecret = new Uint8Array([...newSecret, lastByte - 27]);
}
keysMsg = await decrypt(ciphertext, newSecret);
}
const keysTxt = new TextDecoder().decode(keysMsg);
const [
encryptionPublicKey,
encryptionPrivateKey,
signaturePublicKey,
signaturePrivateKey,
appIdentityAddress,
appIdentityAddressPrivateKey,
address,
addressPrivateKey,
permissionId,
] = keysTxt.split('\n');
return {
encryptionPublicKey,
encryptionPrivateKey,
signaturePublicKey,
signaturePrivateKey,
address: appIdentityAddress,
addressPrivateKey: appIdentityAddressPrivateKey,
address,
addressPrivateKey,
permissionId,
};
};
1 change: 0 additions & 1 deletion packages/hypergraph/src/connect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export const AppIdentityResponse = Schema.Struct({
accountProof: Schema.String,
keyProof: Schema.String,
ciphertext: Schema.String,
nonce: Schema.String,
sessionToken: Schema.String,
address: Schema.String,
appId: Schema.String,
Expand Down
1 change: 0 additions & 1 deletion packages/hypergraph/src/messages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,6 @@ export const RequestConnectCreateAppIdentity = Schema.Struct({
address: Schema.String,
accountAddress: Schema.String,
ciphertext: Schema.String,
nonce: Schema.String,
signaturePublicKey: Schema.String,
encryptionPublicKey: Schema.String,
accountProof: Schema.String,
Expand Down
Loading