@meddleware/seal-ui 0.0.26 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to `@meddleware/seal-ui` are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.0.27] - 2026-10-02
7
+
8
+ - **Mainnet key servers** (workspace ADR-0002, D24): three keyless Open-mode servers run by
9
+ independent operators — Overclock, NodeInfra, H2O Nodes — at threshold 2, replacing the planned
10
+ Enoki committee. Mainnet sealing still waits for the mainnet `seal_policies` package.
11
+ - **No API key path.** `VITE_SEAL_AGGREGATOR_API_KEY_{NET}` is gone; a key in a `VITE_*` var would ship
12
+ in the bundle. A test proves no server is ever configured with one.
13
+ - **Custody notice.** `VITE_SEAL_KEY_CUSTODY_{NET}` (`independent` by default, or `operator` for the
14
+ self-hosted fallback) — with `operator`, the view says the site's operator could decrypt.
15
+ - **Re-seal** (Decrypt tab): decrypts content with the key servers it was sealed to and seals it again
16
+ for the current ones, producing a new manifest (`@meddleware/seal-client` 0.0.12).
17
+ - The publish workflow passes the mainnet server ids, threshold and custody variables, so switching
18
+ servers is a repository-variable change.
19
+
6
20
  ## [0.0.26] - 2026-10-02
7
21
 
8
22
  - Targets the version-gated `seal_policies` (testnet `0x61c4aa…`) through `@meddleware/seal-client`
package/README.md CHANGED
@@ -29,8 +29,8 @@ access-gate-client) for the active network; they are not configuration.
29
29
  | `VITE_NETWORK` | Network the standalone build selects: `testnet` (default) or `mainnet`. Embedded, the host's selector rules. |
30
30
  | `VITE_SEAL_SERVER_OBJECT_IDS_{NET}` | CSV of key-server object ids (the committee) |
31
31
  | `VITE_SEAL_AGGREGATOR_URLS_{NET}` | CSV of aggregator URLs (index-aligned) |
32
- | `VITE_SEAL_AGGREGATOR_API_KEY_{NET}` | Enoki API key sent as `X-API-Key` to aggregator-backed servers (required by the mainnet aggregator; publishable, baked into the bundle) |
33
- | `VITE_SEAL_THRESHOLD_{NET}` | `t` over the configured servers; a committee behind an aggregator counts as one (default testnet 2, mainnet 1). An out-of-range value disables sealing on that network with an explanation |
32
+ | `VITE_SEAL_KEY_CUSTODY_{NET}` | `independent` (default) or `operator`. Set `operator` only when the operator's own key server is in use (the self-hosted fallback); the view then warns that the operator could decrypt |
33
+ | `VITE_SEAL_THRESHOLD_{NET}` | `t` over the configured servers; a committee behind an aggregator counts as one (default 2 on testnet and mainnet). An out-of-range value disables sealing on that network with an explanation |
34
34
  | `VITE_WALRUS_PUBLISHER_{NET}` | Walrus HTTP publisher. Testnet has a default; **mainnet has none** (Walrus runs no public mainnet publisher), so set an operator-run publisher |
35
35
  | `VITE_WALRUS_AGGREGATOR_{NET}` | Walrus HTTP aggregator (Mysten reference endpoints by default) |
36
36
  | `VITE_WALRUS_MAX_UPLOAD_BYTES` | Largest ciphertext sent to the publisher (default 10 MiB, the public-publisher limit) |
@@ -52,7 +52,14 @@ npm run build # vue-tsc + vite → dist/
52
52
  docker build -t seal-ui .
53
53
  ```
54
54
 
55
- Committee mode is testnet-only today; mainnet stays disabled with an in-app notice until it ships.
55
+ Key servers (workspace ADR-0002): testnet uses Mysten's committee and two Open-mode servers; mainnet
56
+ uses three keyless Open-mode servers run by independent operators (Overclock, NodeInfra, H2O Nodes)
57
+ at threshold 2. No server needs an API key, and none is ever put in the bundle. Mainnet sealing stays
58
+ disabled with an in-app notice until the mainnet `seal_policies` package is published.
59
+
60
+ **Re-seal.** Content can only be decrypted by the key servers it was sealed to. The Decrypt tab's
61
+ re-seal action decrypts it with those servers and seals it again for the current ones, so content can
62
+ move off a provider that withdraws while the others still answer.
56
63
 
57
64
  ## License
58
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/seal-ui",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "homepage": "https://docs.meddleware.co.uk/blockchain/sui/sealed-storage/",
5
5
  "description": "Standalone Vue 3 SPA for Sealed Storage — client-side Seal encryption + access-gated, decentralised Walrus storage on Sui.",
6
6
  "author": "MeddleWare <dev@meddleware.co.uk>",
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@meddleware/access-gate-client": "^0.0.2",
48
48
  "@meddleware/design-tokens": "^0.1.7",
49
- "@meddleware/seal-client": "^0.0.11",
49
+ "@meddleware/seal-client": "^0.0.12",
50
50
  "@meddleware/ui": "^0.1.28",
51
51
  "@meddleware/walrus-client": ">=0.0.21 <0.2.0",
52
52
  "@mysten/sui": "^2.33.1",
@@ -19,7 +19,7 @@ import {
19
19
  import { checkManifestForNetwork } from '../manifest-guard.js'
20
20
  import { WalletGuard } from '@meddleware/wallet-adapter'
21
21
  import { useWallet, getSuiClient } from '../wallet.js'
22
- import { registry, getSealController, clearSealSessions } from '../seal.js'
22
+ import { registry, getSealController, clearSealSessions, resealCiphertext } from '../seal.js'
23
23
  import { storeBlob, readBlob } from '../walrus.js'
24
24
  import { discoverSealedContent } from '../sealed-content.js'
25
25
  import { activeConfig, network } from '../config.js'
@@ -293,6 +293,67 @@ async function performDecrypt(): Promise<void> {
293
293
  }
294
294
  }
295
295
 
296
+ // ── Re-seal onto the current key servers ─────────────────────────────────────
297
+ // Content can only be decrypted by the key servers it was sealed to. Re-sealing decrypts it with
298
+ // those servers and encrypts it again for the current ones, so it survives a change of providers
299
+ // (workspace ADR-0002). The result is a new blob and manifest; the old ones keep needing the old
300
+ // servers.
301
+ async function performReseal(): Promise<void> {
302
+ errorMsg.value = null
303
+ status.value = null
304
+ const m = decManifest.value
305
+ const provider = decProvider.value
306
+ if (!m || !provider) {
307
+ errorMsg.value = 'Paste or upload a valid manifest.'
308
+ return
309
+ }
310
+ if (!account.value) {
311
+ errorMsg.value = 'Connect your wallet to re-seal.'
312
+ return
313
+ }
314
+ busy.value = true
315
+ try {
316
+ const encryptParams: Record<string, unknown> = { ...(m.params ?? {}) }
317
+ const decryptParams: Record<string, unknown> = { ...encryptParams }
318
+ for (const f of provider.describe().decryptFields) {
319
+ const v = decValues.value[f.name]
320
+ if (v !== undefined && v !== '') decryptParams[f.name] = coerce(f.kind, v)
321
+ if (f.required && (decryptParams[f.name] === undefined || decryptParams[f.name] === '')) {
322
+ throw new Error(`${f.label} is required.`)
323
+ }
324
+ }
325
+ status.value = 'Fetching ciphertext from Walrus…'
326
+ const ciphertext = await readBlob(m.blobId)
327
+ status.value = 'Requesting keys from the original key servers — approve the signature in your wallet…'
328
+ const resealed = await resealCiphertext(m.policyType, decryptParams, encryptParams, m.id, ciphertext, {
329
+ address: account.value.address,
330
+ signPersonalMessage,
331
+ })
332
+ if (!resealed) {
333
+ status.value = 'This content is already sealed to the current key servers; nothing to do.'
334
+ return
335
+ }
336
+ status.value = 'Storing the re-sealed ciphertext on Walrus…'
337
+ const blobId = await storeBlob(resealed.ciphertext, { sendObjectTo: account.value.address })
338
+ manifest.value = {
339
+ policyType: m.policyType,
340
+ id: resealed.id,
341
+ blobId,
342
+ network: network.value,
343
+ params: encryptParams,
344
+ label: m.label,
345
+ }
346
+ publishDigest.value = null
347
+ status.value = 'Re-sealed. Download the new manifest; the old one still needs the old key servers.'
348
+ tab.value = 'encrypt'
349
+ encStep.value = 2
350
+ } catch (e) {
351
+ errorMsg.value = e instanceof Error ? e.message : String(e)
352
+ } finally {
353
+ busy.value = false
354
+ }
355
+ }
356
+
296
357
  // ── Publish an on-chain discovery pointer (nft-gate content only) ─────────────
297
358
  const publishDigest = ref<string | null>(null)
298
359
 
@@ -394,6 +455,10 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
394
455
  <UiToolIntro>Client-side encrypted, access-gated storage on Walrus + Sui.</UiToolIntro>
395
456
 
396
457
  <p v-if="activeConfig.problem" class="notice notice--warn">{{ activeConfig.problem }}</p>
458
+ <p v-else-if="activeConfig.custody === 'operator'" class="notice notice--warn">
459
+ On {{ activeConfig.network }}, content is sealed with a key server run by this site's operator,
460
+ who could therefore decrypt it. Do not seal anything you would not share with the operator.
461
+ </p>
397
462
 
398
463
  <!-- The tab list and every panel always render (each tab controls a live panel); the wallet
399
464
  prompt replaces only a panel's content until a wallet is connected. Panels stay mounted
@@ -626,6 +691,15 @@ async function performUnlock(item: SealedContentPointer): Promise<void> {
626
691
  {{ busy ? 'Working…' : 'Decrypt' }}
627
692
  </button>
628
693
  </div>
694
+ <p class="muted muted--spaced">
695
+ Moving to new key servers? Re-sealing decrypts this content with the servers it was sealed to
696
+ and seals it again for the current ones, producing a new manifest.
697
+ </p>
698
+ <div class="nav-row">
699
+ <button type="button" class="link" :disabled="busy || disabled || !decProvider" @click="performReseal">
700
+ Re-seal for the current key servers
701
+ </button>
702
+ </div>
629
703
  </template>
630
704
  </WalletGuard>
631
705
  </UiTabPanel>
package/src/config.ts CHANGED
@@ -21,10 +21,11 @@ function csv(v: string | undefined): string[] {
21
21
  }
22
22
 
23
23
  /**
24
- * Mysten key-server defaults. Testnet: the decentralized committee (via its aggregator) plus two
25
- * independent servers. Mainnet: the verified 5-of-8 committee behind the mainnet aggregator, which
26
- * needs an Enoki API key (`VITE_SEAL_AGGREGATOR_API_KEY_MAINNET`, a publishable client key scoped
27
- * to Seal — it is baked into the bundle). A committee counts as ONE server.
24
+ * Key-server defaults (workspace ADR-0002). Testnet: Mysten's decentralized committee (via its
25
+ * aggregator) plus Mysten's two Open-mode servers, threshold 2. Mainnet (D24): three keyless
26
+ * Open-mode servers run by independent operators — Overclock, NodeInfra, H2O Nodes — at threshold 2,
27
+ * so no single operator can decrypt and one may be down. No server needs an API key, so none is ever
28
+ * configured in the bundle. A committee behind an aggregator counts as ONE server.
28
29
  */
29
30
  const SERVER_DEFAULTS: Record<string, { ids: string[]; aggs: string[]; threshold: number }> = {
30
31
  testnet: {
@@ -37,9 +38,13 @@ const SERVER_DEFAULTS: Record<string, { ids: string[]; aggs: string[]; threshold
37
38
  threshold: 2,
38
39
  },
39
40
  mainnet: {
40
- ids: ['0x686098f1439237fff9f36b99c7329683c22979d2005c2465cb891acb012a7595'],
41
- aggs: ['https://seal-aggregator-mainnet.mystenlabs.com'],
42
- threshold: 1,
41
+ ids: [
42
+ '0x145540d931f182fef76467dd8074c9839aea126852d90d18e1556fcbbd1208b6', // Overclock (Open)
43
+ '0x1afb3a57211ceff8f6781757821847e3ddae73f64e78ec8cd9349914ad985475', // NodeInfra (Open)
44
+ '0x4a65b4ff7ba8f4b538895ee35959f982a95f0db7e2a202ec989d261ea927286a', // H2O Nodes (Open)
45
+ ],
46
+ aggs: [],
47
+ threshold: 2,
43
48
  },
44
49
  }
45
50
 
@@ -69,6 +74,14 @@ export function thresholdError(threshold: number, servers: readonly { weight?: n
69
74
  return null
70
75
  }
71
76
 
77
+ /**
78
+ * Who could decrypt content sealed on a network. `independent` (the default): key servers run by
79
+ * parties other than this app's operator, at a threshold no single one meets. `operator`: the
80
+ * operator's own key server is in use (the ADR-0002 fallback), so the operator could decrypt; the
81
+ * view says so.
82
+ */
83
+ export type KeyCustody = 'independent' | 'operator'
84
+
72
85
  /** Everything sealing needs on one network. */
73
86
  export interface SealConfig {
74
87
  network: string
@@ -78,6 +91,7 @@ export interface SealConfig {
78
91
  accessGateOriginalId: string
79
92
  servers: KeyServerConfig[]
80
93
  threshold: number
94
+ custody: KeyCustody
81
95
  walrusPublisher: string
82
96
  walrusAggregator: string
83
97
  /** Why sealing is unavailable on this network, or null when it is configured. */
@@ -96,22 +110,24 @@ export function sealConfig(net: string, envSource: EnvSource = env): SealConfig
96
110
  const envAggs = csv(netEnv('VITE_SEAL_AGGREGATOR_URLS'))
97
111
  const ids = envIds.length ? envIds : (defaults?.ids ?? [])
98
112
  const aggs = envAggs.length ? envAggs : (defaults?.aggs ?? [])
99
- const apiKey = netEnv('VITE_SEAL_AGGREGATOR_API_KEY')
100
- const servers: KeyServerConfig[] = ids.map((objectId, i) => {
101
- const aggregatorUrl = aggs[i]
102
- return aggregatorUrl && apiKey
103
- ? { objectId, weight: 1, aggregatorUrl, apiKeyName: 'X-API-Key', apiKey }
104
- : { objectId, weight: 1, aggregatorUrl }
105
- })
113
+ // Never an API key: a key in a VITE_* var ships in the public bundle (seal-ui audit I5).
114
+ const servers: KeyServerConfig[] = ids.map((objectId, i) => ({ objectId, weight: 1, aggregatorUrl: aggs[i] }))
106
115
  const rawThreshold = netEnv('VITE_SEAL_THRESHOLD')
107
116
  const threshold = rawThreshold ? Number(rawThreshold) : (defaults?.threshold ?? 1)
108
117
 
118
+ const rawCustody = netEnv('VITE_SEAL_KEY_CUSTODY') ?? 'independent'
119
+ const custody: KeyCustody = rawCustody === 'operator' ? 'operator' : 'independent'
120
+ const custodyError =
121
+ rawCustody === 'independent' || rawCustody === 'operator'
122
+ ? null
123
+ : `VITE_SEAL_KEY_CUSTODY_${NET} must be "independent" or "operator"; got "${rawCustody}"`
124
+
109
125
  const walrus = WALRUS_DEFAULTS[net]
110
- const problem = !seal
126
+ const problem = custodyError ?? (!seal
111
127
  ? `Sealed Storage is not available on ${net}: no seal_policies deployment is recorded for it.`
112
128
  : servers.length === 0
113
129
  ? `Sealed Storage is not configured on ${net}: set VITE_SEAL_SERVER_OBJECT_IDS_${NET} (and VITE_SEAL_AGGREGATOR_URLS_${NET} for a committee).`
114
- : thresholdError(threshold, servers)
130
+ : thresholdError(threshold, servers))
115
131
 
116
132
  return {
117
133
  network: net,
@@ -119,12 +135,27 @@ export function sealConfig(net: string, envSource: EnvSource = env): SealConfig
119
135
  accessGateOriginalId: accessGate?.originalId ?? '',
120
136
  servers,
121
137
  threshold,
138
+ custody,
122
139
  walrusPublisher: netEnv('VITE_WALRUS_PUBLISHER') || walrus?.publisher || '',
123
140
  walrusAggregator: netEnv('VITE_WALRUS_AGGREGATOR') || walrus?.aggregator || '',
124
141
  problem,
125
142
  }
126
143
  }
127
144
 
145
+ /**
146
+ * The aggregator URL for a committee server on `net`, from the current configuration or the
147
+ * defaults (independent servers publish their URL on-chain and need none). Used to reach the servers
148
+ * an older ciphertext was sealed to.
149
+ */
150
+ export function aggregatorUrlFor(net: string, objectId: string, envSource: EnvSource = env): string | undefined {
151
+ const norm = (id: string) => `0x${id.toLowerCase().replace(/^0x/, '').padStart(64, '0')}`
152
+ const fromConfig = sealConfig(net, envSource).servers.find((s) => norm(s.objectId) === norm(objectId))
153
+ if (fromConfig?.aggregatorUrl) return fromConfig.aggregatorUrl
154
+ const d = SERVER_DEFAULTS[net]
155
+ const i = d ? d.ids.findIndex((id) => norm(id) === norm(objectId)) : -1
156
+ return i >= 0 ? d?.aggs[i] : undefined
157
+ }
158
+
128
159
  /** The configuration for the active network. */
129
160
  export const activeConfig = computed(() => sealConfig(network.value))
130
161
 
package/src/seal.ts CHANGED
@@ -7,7 +7,7 @@ import { computed } from 'vue'
7
7
  import { createDefaultRegistry, type PolicyRegistry } from '@meddleware/seal-client'
8
8
  import type { SealController } from '@meddleware/seal-client/controller'
9
9
  import { getSuiClient } from './wallet.js'
10
- import { activeConfig, type SealConfig } from './config.js'
10
+ import { activeConfig, aggregatorUrlFor, type SealConfig } from './config.js'
11
11
 
12
12
  const registries = new Map<string, PolicyRegistry>()
13
13
  const controllers = new Map<string, SealController>()
@@ -52,6 +52,50 @@ export async function getSealController(): Promise<SealController> {
52
52
  return c
53
53
  }
54
54
 
55
+ /** A ciphertext re-sealed onto the active network's key servers. */
56
+ export interface Resealed {
57
+ id: string
58
+ ciphertext: Uint8Array
59
+ }
60
+
61
+ /**
62
+ * Re-seal `ciphertext` (identity `id`, sealed under policy `policyType`) onto the active network's
63
+ * current key servers: decrypt with a controller for the servers its header records, then encrypt
64
+ * the plaintext under `encryptParams` with the current controller. Returns `null` when it is already
65
+ * sealed to the current servers. The plaintext never leaves this function.
66
+ *
67
+ * @throws {Error} if sealing is unavailable, the ciphertext belongs to another policy package, or the
68
+ * original servers cannot release the key.
69
+ */
70
+ export async function resealCiphertext(
71
+ policyType: string,
72
+ decryptParams: Record<string, unknown>,
73
+ encryptParams: Record<string, unknown>,
74
+ id: string,
75
+ ciphertext: Uint8Array,
76
+ opts: { address: string; signPersonalMessage: (message: Uint8Array) => Promise<{ signature: string }> },
77
+ ): Promise<Resealed | null> {
78
+ const cfg = activeConfig.value
79
+ if (cfg.problem || !cfg.seal) throw new Error(cfg.problem ?? `Sealed Storage is not available on ${cfg.network}.`)
80
+ const { SealController, describeCiphertext, sealedUnderServers } = await import('@meddleware/seal-client/controller')
81
+ const info = describeCiphertext(ciphertext)
82
+ if (sealedUnderServers(info, cfg.servers, cfg.threshold)) return null
83
+ const original = new SealController(
84
+ {
85
+ suiClient: getSuiClient(),
86
+ originalId: cfg.seal.originalId,
87
+ publishedAt: cfg.seal.publishedAt,
88
+ policyConfigId: cfg.seal.policyConfigId,
89
+ threshold: info.threshold,
90
+ serverConfigs: info.servers.map((s) => ({ ...s, aggregatorUrl: aggregatorUrlFor(cfg.network, s.objectId) })),
91
+ },
92
+ registryFor(cfg),
93
+ )
94
+ const plaintext = await original.decrypt(policyType, decryptParams, id, ciphertext, opts)
95
+ original.clearSession()
96
+ return (await getSealController()).encrypt(policyType, encryptParams, plaintext)
97
+ }
98
+
55
99
  /**
56
100
  * Drop every cached SessionKey (wallet disconnected or switched account). A no-op before a
57
101
  * controller has been loaded, so calling it never pulls `@mysten/seal` into the bundle.