Skip to content

Commit 099300a

Browse files
committed
[seal] handle committee v2 servers
1 parent 40f7878 commit 099300a

File tree

8 files changed

+255
-26
lines changed

8 files changed

+255
-26
lines changed

packages/seal/src/bcs.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,44 @@ export const KeyServerMoveV1 = bcs.struct('KeyServerV1', {
5151
pk: bcs.byteVector(),
5252
});
5353

54+
/**
55+
* The Move struct for PartialKeyServer (committee member).
56+
*/
57+
export const PartialKeyServer = bcs.struct('PartialKeyServer', {
58+
partialPk: bcs.byteVector(),
59+
url: bcs.string(),
60+
partyId: bcs.u16(),
61+
});
62+
63+
/**
64+
* The Move enum for ServerType (V2).
65+
*/
66+
export const ServerType = bcs.enum('ServerType', {
67+
Independent: bcs.struct('Independent', {
68+
url: bcs.string(),
69+
}),
70+
Committee: bcs.struct('Committee', {
71+
version: bcs.u32(),
72+
threshold: bcs.u16(),
73+
partialKeyServers: bcs.vector(
74+
bcs.struct('VecMapEntry', {
75+
key: bcs.Address,
76+
value: PartialKeyServer,
77+
}),
78+
),
79+
}),
80+
});
81+
82+
/**
83+
* The Move struct for the KeyServerV2 object.
84+
*/
85+
export const KeyServerMoveV2 = bcs.struct('KeyServerV2', {
86+
name: bcs.string(),
87+
keyType: bcs.u8(),
88+
pk: bcs.byteVector(),
89+
serverType: ServerType,
90+
});
91+
5492
/**
5593
* The Move struct for the parent object.
5694
*/

packages/seal/src/client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,26 @@ export class SealClient {
303303
throw new InvalidKeyServerError('No key servers found');
304304
}
305305

306+
// Set aggregator URLs for committee servers from config
307+
keyServers.forEach((server) => {
308+
const config = this.#configs.get(server.objectId);
309+
if (server.serverType === 'Committee') {
310+
if (!config?.aggregatorUrl) {
311+
throw new InvalidClientOptionsError(
312+
`Committee server ${server.objectId} requires aggregatorUrl in config`,
313+
);
314+
}
315+
server.url = config.aggregatorUrl;
316+
}
317+
});
318+
306319
if (this.#verifyKeyServers) {
307320
await Promise.all(
308321
keyServers.map(async (server) => {
322+
// Skip /service verification for committee/aggregator servers
323+
if (server.serverType === 'Committee') {
324+
return;
325+
}
309326
const config = this.#configs.get(server.objectId);
310327
if (!(await verifyKeyServer(server, this.#timeout, config?.apiKeyName, config?.apiKey))) {
311328
throw new InvalidKeyServerError(`Key server ${server.objectId} is not valid`);

packages/seal/src/key-server.ts

Lines changed: 81 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { bcs, fromBase64, fromHex, toBase64, toHex } from '@mysten/bcs';
44
import { bls12_381 } from '@noble/curves/bls12-381';
55

6-
import { KeyServerMove, KeyServerMoveV1 } from './bcs.js';
6+
import { KeyServerMove, KeyServerMoveV1, KeyServerMoveV2 } from './bcs.js';
77
import { InvalidKeyServerError, InvalidKeyServerVersionError, SealAPIError } from './error.js';
88
import { DST_POP } from './ibe.js';
99
import { PACKAGE_VERSION } from './version.js';
@@ -13,14 +13,14 @@ import { flatten, Version } from './utils.js';
1313
import { elgamalDecrypt } from './elgamal.js';
1414
import type { Certificate } from './session-key.js';
1515

16-
const EXPECTED_SERVER_VERSION = 1;
17-
1816
export type KeyServer = {
1917
objectId: string;
2018
name: string;
2119
url: string;
2220
keyType: KeyServerType;
2321
pk: Uint8Array<ArrayBuffer>;
22+
/** Server type: 'Independent' or 'Committee' */
23+
serverType: 'Independent' | 'Committee';
2424
};
2525

2626
export enum KeyServerType {
@@ -33,6 +33,9 @@ export const SERVER_VERSION_REQUIREMENT = new Version('0.4.1');
3333
* Given a list of key server object IDs, returns a list of SealKeyServer
3434
* from onchain state containing name, objectId, URL and pk.
3535
*
36+
* Supports both V1 (independent servers) and V2 (independent + committee servers).
37+
* For V2 committee servers, returns the aggregator URL which clients use to fetch keys.
38+
*
3639
* @param objectIds - The key server object IDs.
3740
* @param client - The SuiClient to use.
3841
* @returns - An array of SealKeyServer.
@@ -51,39 +54,94 @@ export async function retrieveKeyServers({
5154
objectId,
5255
});
5356
const ks = KeyServerMove.parse(await res.object.content);
54-
if (
55-
EXPECTED_SERVER_VERSION < Number(ks.firstVersion) ||
56-
EXPECTED_SERVER_VERSION > Number(ks.lastVersion)
57-
) {
57+
58+
// Determine which version to use (prefer V2 if available)
59+
let version: number;
60+
if (Number(ks.firstVersion) <= 2 && Number(ks.lastVersion) >= 2) {
61+
version = 2;
62+
} else if (Number(ks.firstVersion) <= 1 && Number(ks.lastVersion) >= 1) {
63+
version = 1;
64+
} else {
5865
throw new InvalidKeyServerVersionError(
59-
`Key server ${objectId} supports versions between ${ks.firstVersion} and ${ks.lastVersion} (inclusive), but SDK expects version ${EXPECTED_SERVER_VERSION}`,
66+
`Key server ${objectId} supports versions between ${ks.firstVersion} and ${ks.lastVersion} (inclusive), but SDK expects version 1 or 2`,
6067
);
6168
}
6269

63-
// Then fetch the expected versioned object and parse it.
70+
// Fetch the versioned object
6471
const resVersionedKs = await client.core.getDynamicField({
6572
parentId: objectId,
6673
name: {
6774
type: 'u64',
68-
bcs: bcs.u64().serialize(EXPECTED_SERVER_VERSION).toBytes(),
75+
bcs: bcs.u64().serialize(version).toBytes(),
6976
},
7077
});
7178

72-
const ksVersioned = KeyServerMoveV1.parse(resVersionedKs.dynamicField.value.bcs);
79+
if (version === 2) {
80+
// Parse V2 key server (supports both Independent and Committee modes)
81+
const ksV2 = KeyServerMoveV2.parse(resVersionedKs.dynamicField.value.bcs);
7382

74-
if (ksVersioned.keyType !== KeyServerType.BonehFranklinBLS12381) {
75-
throw new InvalidKeyServerError(
76-
`Server ${objectId} has invalid key type: ${ksVersioned.keyType}`,
77-
);
78-
}
83+
// Handle Move hex literal format: x"0x..."
84+
// If pk starts with 'x"0x', it's stored as ASCII and needs to be converted
85+
let pk = ksV2.pk;
86+
if (pk.length > 96 && pk[0] === 120 && pk[1] === 34 && pk[2] === 48 && pk[3] === 120) {
87+
// Decode Move hex literal: x"0x..." -> bytes
88+
const hexStr = String.fromCharCode(...pk);
89+
// Extract hex string between x"0x and "
90+
const match = hexStr.match(/^x"(0x[0-9a-fA-F]+)"$/);
91+
if (match) {
92+
pk = new Uint8Array(fromHex(match[1]));
93+
}
94+
}
7995

80-
return {
81-
objectId,
82-
name: ksVersioned.name,
83-
url: ksVersioned.url,
84-
keyType: ksVersioned.keyType,
85-
pk: new Uint8Array(ksVersioned.pk),
86-
};
96+
if (ksV2.keyType !== KeyServerType.BonehFranklinBLS12381) {
97+
throw new InvalidKeyServerError(
98+
`Server ${objectId} has invalid key type: ${ksV2.keyType}`,
99+
);
100+
}
101+
102+
// Extract URL and server type
103+
let url: string;
104+
let serverType: 'Independent' | 'Committee';
105+
106+
if (ksV2.serverType.$kind === 'Independent') {
107+
url = ksV2.serverType.Independent.url;
108+
serverType = 'Independent';
109+
} else if (ksV2.serverType.$kind === 'Committee') {
110+
// For committee mode, URL will be provided by client config
111+
// The aggregator is not registered onchain
112+
url = ''; // Placeholder, will be replaced by config
113+
serverType = 'Committee';
114+
} else {
115+
throw new InvalidKeyServerError(`Unknown server type for ${objectId}`);
116+
}
117+
118+
return {
119+
objectId,
120+
name: ksV2.name,
121+
url,
122+
keyType: ksV2.keyType,
123+
pk: new Uint8Array(pk), // Aggregated public key (converted from Move hex literal if needed)
124+
serverType,
125+
};
126+
} else {
127+
// Parse V1 key server (backward compatibility)
128+
const ksV1 = KeyServerMoveV1.parse(resVersionedKs.dynamicField.value.bcs);
129+
130+
if (ksV1.keyType !== KeyServerType.BonehFranklinBLS12381) {
131+
throw new InvalidKeyServerError(
132+
`Server ${objectId} has invalid key type: ${ksV1.keyType}`,
133+
);
134+
}
135+
136+
return {
137+
objectId,
138+
name: ksV1.name,
139+
url: ksV1.url,
140+
keyType: ksV1.keyType,
141+
pk: new Uint8Array(ksV1.pk),
142+
serverType: 'Independent',
143+
};
144+
}
87145
}),
88146
);
89147
}

packages/seal/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface KeyServerConfig {
3636
weight: number;
3737
apiKeyName?: string;
3838
apiKey?: string;
39+
/** Optional aggregator URL for committee mode. When provided, the SDK will use this URL instead of fetching individual committee member servers. */
40+
aggregatorUrl?: string;
3941
}
4042

4143
export interface SealClientOptions extends SealClientExtensionOptions {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) Mysten Labs, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { fromHex } from '@mysten/bcs';
5+
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
6+
import { Transaction } from '@mysten/sui/transactions';
7+
import { getFullnodeUrl, SuiClient } from '@mysten/sui/client';
8+
import { describe, it, expect } from 'vitest';
9+
10+
import { SealClient } from '../../src/client.js';
11+
import { SessionKey } from '../../src/session-key.js';
12+
13+
/**
14+
* Committee Aggregator Integration Tests
15+
*
16+
* These tests require a running aggregator server and committee key servers.
17+
* To start the servers, run from the seal repository root:
18+
*
19+
* cd /path/to/seal
20+
* bash scripts/start-committee-servers.sh
21+
*
22+
* This will start:
23+
* - 3 committee key servers on ports 2024, 2025, 2026
24+
* - 1 aggregator server on port 2027
25+
*
26+
* To run these tests:
27+
* pnpm test test/unit/aggregator.test.ts
28+
*/
29+
describe('Committee Aggregator Tests', () => {
30+
it('[committee aggregator] encrypt and decrypt through aggregator', { timeout: 12000 }, async () => {
31+
// Committee key server configuration
32+
const COMMITTEE_KEY_SERVER_OBJ_ID = '0x56eee177d0e3d544354616610d3e06edd65a10f47f0056d111e4d241d70c1969';
33+
const AGGREGATOR_URL = 'http://localhost:2027';
34+
const PACKAGE_ID = '0x58dce5d91278bceb65d44666ffa225ab397fc3ae9d8398c8c779c5530bd978c2'; // Testnet package with account_based policy
35+
36+
// Generate fresh keypair for this test (sui address can be any keypair)
37+
const testKeypair = Ed25519Keypair.generate();
38+
const testAddress = testKeypair.getPublicKey().toSuiAddress();
39+
40+
const testData = crypto.getRandomValues(new Uint8Array(100));
41+
42+
// Create a fresh Sui client for this test
43+
const suiClient = new SuiClient({ url: getFullnodeUrl('testnet') });
44+
45+
// Create Seal client with aggregator URL
46+
const client = new SealClient({
47+
suiClient,
48+
serverConfigs: [
49+
{
50+
objectId: COMMITTEE_KEY_SERVER_OBJ_ID,
51+
weight: 1,
52+
aggregatorUrl: AGGREGATOR_URL,
53+
},
54+
],
55+
verifyKeyServers: false, // Skip /service verification for aggregator
56+
});
57+
58+
// Encrypt data using committee public key (use account_based policy)
59+
const { encryptedObject: encryptedBytes} = await client.encrypt({
60+
threshold: 1, // Committee threshold
61+
packageId: PACKAGE_ID,
62+
id: testAddress,
63+
data: testData,
64+
});
65+
66+
// Create session key
67+
const sessionKey = await SessionKey.create({
68+
address: testAddress,
69+
packageId: PACKAGE_ID,
70+
ttlMin: 10,
71+
signer: testKeypair,
72+
suiClient,
73+
});
74+
75+
// Build transaction (account_based policy)
76+
const tx = new Transaction();
77+
const keyIdArg = tx.pure.vector('u8', fromHex(testAddress));
78+
tx.moveCall({
79+
target: `${PACKAGE_ID}::account_based::seal_approve`,
80+
arguments: [keyIdArg],
81+
});
82+
const txBytes = await tx.build({ client: suiClient, onlyTransactionKind: true });
83+
84+
// Decrypt data through aggregator
85+
const decryptedData = await client.decrypt({
86+
data: encryptedBytes,
87+
sessionKey,
88+
txBytes,
89+
});
90+
91+
// Verify decrypted data matches original
92+
expect(decryptedData).toEqual(testData);
93+
});
94+
});

0 commit comments

Comments
 (0)