@volter/world-core 2.0.17 → 2.0.18

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.
@@ -14,6 +14,10 @@ type SealedBody = {
14
14
  placedAt: string;
15
15
  fingerprint: string;
16
16
  rotatedAt?: string;
17
+ /** where the credential may be sent (`origin=https://…`), part of the AEAD's associated data: a record opens for
18
+ * its own destination and no other, so a root or link pointed elsewhere never carries it there (the executor also
19
+ * sends it to the hosts its vendor's pack declares: code, not configuration) */
20
+ bound?: string;
17
21
  };
18
22
  export type SealedByVault = SealedBody & {
19
23
  v: 2;
@@ -33,6 +37,8 @@ export type SealedUnderKey = {
33
37
  /** when a vendor last rotated a field of it (an OAuth refresh token): placedAt and fingerprint stay the credential's
34
38
  * identity across rotations, the ledgers keyed by the fingerprint with them */
35
39
  rotatedAt?: string;
40
+ /** as SealedBody's: the destination the credential is bound to, in its associated data */
41
+ bound?: string;
36
42
  };
37
43
  export interface CredentialStorage {
38
44
  getSealed(namespace: string, vendor: string): Promise<SealedCredential | null>;
@@ -50,13 +56,15 @@ export declare class MemoryCredentialStorage implements CredentialStorage {
50
56
  deleteSealed(namespace: string, vendor: string): Promise<void>;
51
57
  deleteNamespace(namespace: string): Promise<void>;
52
58
  }
53
- export declare function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedUnderKey>;
54
- export declare function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
59
+ export declare function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot?: string, bound?: string): Promise<SealedUnderKey>;
60
+ export declare function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot?: string, bound?: string): Promise<CredentialPayload>;
55
61
  /** Seals and opens credentials. A host chooses one (`setSealer`); a key it holds (`keySealer`), or a vault that holds
56
62
  * the key and wraps for it (`transitSealer`). */
57
63
  export interface Sealer {
58
- seal(payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedCredential>;
59
- open(sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
64
+ /** `bound`: the destination the credential may be sent to, sealed into the record (see SealedBody) */
65
+ seal(payload: CredentialPayload, placedAt: string, slot?: string, bound?: string): Promise<SealedCredential>;
66
+ /** `bound`: the destination it is opened for; a record bound elsewhere refuses */
67
+ open(sealed: SealedCredential, slot?: string, bound?: string): Promise<CredentialPayload>;
60
68
  }
61
69
  /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
62
70
  export declare function keySealer(kekSecret: () => string): Sealer;
@@ -73,15 +81,18 @@ export type TransitSealerOptions = {
73
81
  /** opens `v: 1` records sealed before the host moved to the vault; seals nothing */
74
82
  keyForV1?: () => string;
75
83
  /** the transit key that keys every fingerprint (default `<key>-fingerprint`): one that is never rotated, so a
76
- * credential's fingerprint, and every ledger keyed by it, stays as it was while the wrapping key is rotated, its
77
- * min_encryption_version raised and its old versions trimmed (an hmac at a pinned version of a rotated key is
78
- * refused once that version is too old) */
84
+ * credential's fingerprint, and every ledger keyed by it, stays as it was while the wrapping key is rotated and its
85
+ * min_encryption_version raised (an hmac at a pinned version of a rotated key is refused once that version is too
86
+ * old). A wrapping key's old versions are never trimmed: a record's DEK stays under the version that wrapped it */
79
87
  fingerprintKey?: string;
80
88
  /** how long an opened DEK is kept in memory, ms (default 60 000): a burst of vendor calls asks the vault once */
81
89
  dekCacheMs?: number;
82
90
  /** how long a vault call may take, ms (default 10 000) */
83
91
  timeoutMs?: number;
84
92
  fetch?: typeof fetch;
93
+ /** renew the token (auth/token/renew-self) after calls, at most daily (default true); false where something else
94
+ * holds and renews it (the hosted Worlds' supervisor) */
95
+ renew?: boolean;
85
96
  };
86
97
  /**
87
98
  * OpenBao's (or Vault's) transit engine wraps each credential's DEK: the key never leaves the vault. The payload is
@@ -23,6 +23,19 @@
23
23
  // (docs/concepts/the-model.md#the-rules, rule 5, "applies the credential by strategy"). It is optional and unread unless
24
24
  // the pack declares such a strategy, so a header-authenticated vendor seals exactly what it sealed
25
25
  // before. It is never logged and never enters pack code — the executor is the only reader.
26
+ /** The associated data a record is sealed under: its slot, and its destination when it has one. */
27
+ const associatedData = (slot, bound) => new Uint8Array(new TextEncoder().encode(bound === undefined ? slot : `${slot}\u0000${bound}`));
28
+ /** A record opened for a destination: one bound elsewhere refuses, and so does one bound nowhere (sealed before
29
+ * bindings, until bindLegacyCredentials binds it as its World wakes). With no destination asked (a webhook's signing
30
+ * secret, sent nowhere, or that binding itself), the record opens under the binding it carries. */
31
+ function checkBinding(sealed, bound) {
32
+ if (bound === undefined)
33
+ return;
34
+ if (sealed.bound === undefined)
35
+ throw new Error(`credential sealed before it was bound to a destination: set it again for ${bound.replace(/^origin=/, '')}`);
36
+ if (sealed.bound !== bound)
37
+ throw new Error(`credential bound to ${sealed.bound}, not ${bound}: set it again for this destination`);
38
+ }
26
39
  export class MemoryCredentialStorage {
27
40
  records = new Map();
28
41
  async getSealed(namespace, vendor) {
@@ -66,7 +79,7 @@ async function importKek(secret) {
66
79
  const material = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
67
80
  return await crypto.subtle.importKey('raw', material, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
68
81
  }
69
- export async function sealCredential(kekSecret, payload, placedAt, slot = '') {
82
+ export async function sealCredential(kekSecret, payload, placedAt, slot = '', bound) {
70
83
  const kek = await importKek(kekSecret);
71
84
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
72
85
  const dekBytes = crypto.getRandomValues(new Uint8Array(32));
@@ -75,7 +88,7 @@ export async function sealCredential(kekSecret, payload, placedAt, slot = '') {
75
88
  const dekIv = crypto.getRandomValues(new Uint8Array(12));
76
89
  // The SLOT ('{namespace}/{vendor}') rides as AAD (audit L16): a sealed record
77
90
  // transplanted to another slot by a bucket-level writer refuses to open there.
78
- const aad = new TextEncoder().encode(slot);
91
+ const aad = associatedData(slot, bound);
79
92
  const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, dek, plaintext));
80
93
  const wrappedDek = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dekIv }, kek, dekBytes));
81
94
  // KEYED fingerprint (review m1): an unsalted hash prefix is an offline oracle for
@@ -92,9 +105,11 @@ export async function sealCredential(kekSecret, payload, placedAt, slot = '') {
92
105
  ciphertext: toB64(ciphertext),
93
106
  placedAt,
94
107
  fingerprint: [...digest.slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join(''),
108
+ ...(bound === undefined ? {} : { bound }),
95
109
  };
96
110
  }
97
- export async function openSealedCredential(kekSecret, sealed, slot = '') {
111
+ export async function openSealedCredential(kekSecret, sealed, slot = '', bound) {
112
+ checkBinding(sealed, bound);
98
113
  if (sealed.v === 2)
99
114
  throw new Error(`credential sealed by a vault (${sealed.transitKey}): open it with that vault's sealer`);
100
115
  const kek = await importKek(kekSecret);
@@ -107,7 +122,7 @@ export async function openSealedCredential(kekSecret, sealed, slot = '') {
107
122
  }
108
123
  const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['decrypt']);
109
124
  try {
110
- const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: new TextEncoder().encode(slot) }, dek, fromB64(sealed.ciphertext)));
125
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: associatedData(slot, sealed.bound) }, dek, fromB64(sealed.ciphertext)));
111
126
  return JSON.parse(new TextDecoder().decode(plaintext));
112
127
  }
113
128
  catch {
@@ -117,8 +132,8 @@ export async function openSealedCredential(kekSecret, sealed, slot = '') {
117
132
  /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
118
133
  export function keySealer(kekSecret) {
119
134
  return {
120
- seal: (payload, placedAt, slot = '') => sealCredential(kekSecret(), payload, placedAt, slot),
121
- open: (sealed, slot = '') => openSealedCredential(kekSecret(), sealed, slot),
135
+ seal: (payload, placedAt, slot = '', bound) => sealCredential(kekSecret(), payload, placedAt, slot, bound),
136
+ open: (sealed, slot = '', bound) => openSealedCredential(kekSecret(), sealed, slot, bound),
122
137
  };
123
138
  }
124
139
  /**
@@ -130,13 +145,18 @@ export function transitSealer(options) {
130
145
  const mount = options.mount ?? 'transit';
131
146
  const named = `${mount}/${options.key}`;
132
147
  const addr = options.addr.replace(/\/+$/, '');
148
+ // the token, every DEK and every credential (hmac) go to this address: https, or http only to this machine
149
+ const parsed = new URL(addr);
150
+ if (parsed.username || parsed.password || !(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && /^(127(\.\d{1,3}){3}|localhost|\[::1\])$/.test(parsed.hostname)))) {
151
+ throw new Error(`the vault address ${parsed.protocol}//${parsed.host} is neither https nor this machine`);
152
+ }
133
153
  const fingerprintKey = options.fingerprintKey ?? `${options.key}-fingerprint`;
134
154
  // a periodic token lives while it is renewed within its period: after a call it answered, at most once a day, the
135
155
  // sealer renews its own. A vault that refuses (a token that is not renewable, a root or dev token) is asked again the
136
156
  // next day; one that did not answer, after the next call. The token's own period is the bound.
137
157
  let renewedAt = 0;
138
158
  const renew = async () => {
139
- if (Date.now() - renewedAt < 86_400_000)
159
+ if (options.renew === false || Date.now() - renewedAt < 86_400_000)
140
160
  return;
141
161
  renewedAt = Date.now();
142
162
  let status;
@@ -146,6 +166,8 @@ export function transitSealer(options) {
146
166
  headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
147
167
  body: '{}',
148
168
  signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
169
+ // never followed: a redirect would carry the token (and the DEK or credential) to wherever it points
170
+ redirect: 'manual',
149
171
  })).status;
150
172
  }
151
173
  catch (error) {
@@ -167,11 +189,15 @@ export function transitSealer(options) {
167
189
  headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
168
190
  body: JSON.stringify(body),
169
191
  signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
192
+ // never followed: a redirect would carry the token (and the DEK or credential) to wherever it points
193
+ redirect: 'manual',
170
194
  });
171
195
  }
172
196
  catch (error) {
173
197
  throw new Error(`the vault at ${addr} did not answer transit ${op} with ${which} (${error.message})`);
174
198
  }
199
+ if (response.status >= 300 && response.status < 400)
200
+ throw new Error(`the vault at ${addr} redirected transit ${op}: refused`);
175
201
  if (!response.ok)
176
202
  throw new Error(`the vault refused transit ${op} with ${which} (${response.status})`);
177
203
  const answer = (await response.json());
@@ -202,28 +228,29 @@ export function transitSealer(options) {
202
228
  return dek;
203
229
  };
204
230
  return {
205
- async seal(payload, placedAt, slot = '') {
231
+ async seal(payload, placedAt, slot = '', bound) {
206
232
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
207
233
  const dekBytes = crypto.getRandomValues(new Uint8Array(32));
208
234
  const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['encrypt']);
209
235
  const iv = crypto.getRandomValues(new Uint8Array(12));
210
- const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(slot) }, dek, plaintext));
236
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: associatedData(slot, bound) }, dek, plaintext));
211
237
  const { ciphertext: wrappedDek } = await call('encrypt', { plaintext: toB64(dekBytes) });
212
238
  if (!wrappedDek)
213
239
  throw new Error(`the vault answered transit encrypt with ${named} without a ciphertext`);
214
- return { v: 2, transitKey: named, wrappedDek, iv: toB64(iv), ciphertext: toB64(ciphertext), placedAt, fingerprint: await fingerprintOf(plaintext) };
240
+ return { v: 2, transitKey: named, wrappedDek, iv: toB64(iv), ciphertext: toB64(ciphertext), placedAt, fingerprint: await fingerprintOf(plaintext), ...(bound === undefined ? {} : { bound }) };
215
241
  },
216
- async open(sealed, slot = '') {
242
+ async open(sealed, slot = '', bound) {
217
243
  if (sealed.v === 1) {
218
244
  if (!options.keyForV1)
219
245
  throw new Error('credential sealed under a key (v1), and this host has no key for v1 records');
220
- return openSealedCredential(options.keyForV1(), sealed, slot);
246
+ return openSealedCredential(options.keyForV1(), sealed, slot, bound);
221
247
  }
248
+ checkBinding(sealed, bound);
222
249
  if (sealed.transitKey !== named)
223
250
  throw new Error(`credential sealed by ${sealed.transitKey}, not ${named}`);
224
251
  const dek = await openedDek(sealed.wrappedDek);
225
252
  try {
226
- const out = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: new TextEncoder().encode(slot) }, dek, fromB64(sealed.ciphertext)));
253
+ const out = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: associatedData(slot, sealed.bound) }, dek, fromB64(sealed.ciphertext)));
227
254
  return JSON.parse(new TextDecoder().decode(out));
228
255
  }
229
256
  catch {
@@ -31,23 +31,37 @@ export declare function setSealingKeySource(source: (() => string) | null): void
31
31
  export declare function sealingKey(): string;
32
32
  export declare function setSealer(sealer: Sealer | null): void;
33
33
  export declare function currentSealer(): Sealer;
34
+ /** The destination a credential is bound to (SealedBody.bound): the origin it is sent to, and only that. */
35
+ export declare function destinationOf(url: string): string;
34
36
  /**
35
- * Open the credential sealed at `path`, under the current sealer. A record the host's vault did not seal (`v: 1`, from
36
- * before the host moved to it) is sealed again by the vault as it opens, keeping its placedAt and fingerprint (its
37
- * identity, as a rotation keeps them), so the deployment's own key opens it once and never again (decision 0034).
37
+ * Open the credential sealed at `path`, under the current sealer, for `bound` (destinationOf the place it is about to
38
+ * be sent; omitted where it is sent nowhere, a webhook's signing secret). A record bound elsewhere, or bound nowhere,
39
+ * refuses. A record the host's vault did not seal (`v: 1`) is sealed again by the vault (decision 0034), keeping its
40
+ * binding, placedAt and fingerprint (its identity, as a rotation keeps them). The rewrite lands only over the record that was read; if another came meanwhile, that one is opened instead,
41
+ * so a credential replaced while this one moved is never handed out.
38
42
  */
39
- export declare function openSealedAt(path: string, slot: string): Promise<CredentialPayload | null>;
43
+ export declare function openSealedAt(path: string, slot: string, bound?: string): Promise<CredentialPayload | null>;
44
+ /** Whether this host seals through a vault (setSealer): what a record under the host's key is moved to. */
45
+ export declare function sealsThroughVault(): boolean;
46
+ /** openSealedAt, with the exact record it answers from: the text it read, or the text its own move wrote. A rotation
47
+ * lands only over that text (rootCustody), so it never mistakes another record for the one it opened. */
48
+ export declare function openSealedRecord(path: string, slot: string, bound?: string, again?: boolean): Promise<{
49
+ payload: CredentialPayload;
50
+ text: string;
51
+ } | null>;
40
52
  /** A root as a world materializes it: the config plus the path of the sealed credential. */
41
53
  export type BoundRoot = RootConfig & {
42
54
  credential: string;
43
55
  };
44
- /** Open the credential a root names, under the current sealer. */
56
+ /** Open the credential a root names, under the current sealer, for the root's own origin. */
45
57
  export declare function openRootCredential(root: BoundRoot, vendor?: string): Promise<CredentialPayload>;
46
- /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
47
- * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
48
- * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. */
49
- export declare function resealRootCredential(root: BoundRoot, payload: CredentialPayload, vendor?: string): Promise<void>;
50
- /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one. */
58
+ /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key, slot and
59
+ * binding. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
60
+ * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. `source` is the record
61
+ * the rotation began from: if another credential was set meanwhile, the rotation is dropped, never written over it. */
62
+ export declare function resealRootCredential(root: BoundRoot, payload: CredentialPayload, vendor?: string, source?: string | null): Promise<void>;
63
+ /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one, which
64
+ * lands only over the record that was opened. */
51
65
  export declare function rootCustody(root: BoundRoot, vendor?: string): CredentialCustody;
52
66
  /** The vendor a root belongs to: its credential is sealed under the vendor's name. */
53
67
  export declare function vendorOf(root: BoundRoot): string;
package/dist/src/head.js CHANGED
@@ -65,58 +65,97 @@ export function sealingKey() { return sealingKeySource ? sealingKeySource() : us
65
65
  let hostSealer = null;
66
66
  export function setSealer(sealer) { hostSealer = sealer; }
67
67
  export function currentSealer() { return hostSealer ?? keySealer(sealingKey); }
68
+ /** The destination a credential is bound to (SealedBody.bound): the origin it is sent to, and only that. */
69
+ export function destinationOf(url) { return `origin=${new URL(url).origin}`; }
68
70
  /**
69
- * Open the credential sealed at `path`, under the current sealer. A record the host's vault did not seal (`v: 1`, from
70
- * before the host moved to it) is sealed again by the vault as it opens, keeping its placedAt and fingerprint (its
71
- * identity, as a rotation keeps them), so the deployment's own key opens it once and never again (decision 0034).
71
+ * Open the credential sealed at `path`, under the current sealer, for `bound` (destinationOf the place it is about to
72
+ * be sent; omitted where it is sent nowhere, a webhook's signing secret). A record bound elsewhere, or bound nowhere,
73
+ * refuses. A record the host's vault did not seal (`v: 1`) is sealed again by the vault (decision 0034), keeping its
74
+ * binding, placedAt and fingerprint (its identity, as a rotation keeps them). The rewrite lands only over the record that was read; if another came meanwhile, that one is opened instead,
75
+ * so a credential replaced while this one moved is never handed out.
72
76
  */
73
- export async function openSealedAt(path, slot) {
77
+ export async function openSealedAt(path, slot, bound) {
78
+ return (await openSealedRecord(path, slot, bound))?.payload ?? null;
79
+ }
80
+ /** Whether this host seals through a vault (setSealer): what a record under the host's key is moved to. */
81
+ export function sealsThroughVault() { return hostSealer !== null; }
82
+ /** openSealedAt, with the exact record it answers from: the text it read, or the text its own move wrote. A rotation
83
+ * lands only over that text (rootCustody), so it never mistakes another record for the one it opened. */
84
+ export async function openSealedRecord(path, slot, bound, again = true) {
74
85
  const store = getActiveWorldStore();
75
86
  const text = store.read(path);
76
87
  if (text === null)
77
88
  return null;
78
89
  const sealed = JSON.parse(text);
79
- const payload = await currentSealer().open(sealed, slot);
80
- if (hostSealer && sealed.v === 1) {
90
+ const payload = await currentSealer().open(sealed, slot, bound);
91
+ const move = hostSealer !== null && sealed.v === 1;
92
+ let wrote = null;
93
+ if (move) {
81
94
  try {
82
- const moved = await hostSealer.seal(payload, sealed.placedAt, slot);
83
- // only over the record that was read: a credential set or rotated meanwhile stands (read and write are
84
- // synchronous, so nothing comes between this check and the write)
85
- if (store.read(path) === text)
86
- store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
95
+ const resealed = await currentSealer().seal(payload, sealed.placedAt, slot, sealed.bound);
96
+ // read and write are synchronous, so nothing comes between this check and the write
97
+ if (store.read(path) === text) {
98
+ const moved = `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`;
99
+ store.write(path, moved, { secret: true });
100
+ wrote = moved;
101
+ }
87
102
  }
88
103
  catch (error) {
89
- // the credential opened: a move that fails (the vault down, a token expired) waits for the next open
104
+ // the credential opened: a rewrite that fails (the vault down, a token expired) waits for the next open
90
105
  console.warn(`[credential] ${slot}: not moved to the vault yet (${error.message})`);
91
106
  }
92
107
  }
93
- return payload;
108
+ const answeredFrom = wrote ?? text;
109
+ if (store.read(path) !== answeredFrom)
110
+ return again ? openSealedRecord(path, slot, bound, false) : null;
111
+ return { payload, text: answeredFrom };
94
112
  }
95
- /** Open the credential a root names, under the current sealer. */
113
+ /** Open the credential a root names, under the current sealer, for the root's own origin. */
96
114
  export async function openRootCredential(root, vendor = vendorOf(root)) {
97
- const payload = await openSealedAt(root.credential, vendor);
98
- if (payload === null)
115
+ return (await openRootRecord(root, vendor)).payload;
116
+ }
117
+ async function openRootRecord(root, vendor) {
118
+ const opened = await openSealedRecord(root.credential, vendor, destinationOf(root.url));
119
+ if (opened === null)
99
120
  throw new Error(`no credential sealed for ${vendor} — \`printf '<token>' | volter twin ${vendor} credential\``);
100
- return payload;
121
+ return opened;
101
122
  }
102
- /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
103
- * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
104
- * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. */
105
- export async function resealRootCredential(root, payload, vendor = vendorOf(root)) {
123
+ /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key, slot and
124
+ * binding. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
125
+ * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. `source` is the record
126
+ * the rotation began from: if another credential was set meanwhile, the rotation is dropped, never written over it. */
127
+ export async function resealRootCredential(root, payload, vendor = vendorOf(root), source) {
106
128
  const store = getActiveWorldStore();
107
129
  const held = store.read(root.credential);
130
+ if (source !== undefined && held !== source)
131
+ throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
108
132
  const before = held === null ? null : JSON.parse(held);
109
133
  const now = new Date().toISOString();
110
- const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor);
134
+ const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor, destinationOf(root.url));
135
+ // read and write are synchronous: nothing comes between this check and the write
136
+ if (store.read(root.credential) !== held)
137
+ throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
111
138
  store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
112
139
  }
113
- /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one. */
140
+ /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one, which
141
+ * lands only over the record that was opened. */
114
142
  export function rootCustody(root, vendor = vendorOf(root)) {
115
143
  // the credential's identity: its path and its fingerprint, which a rotation keeps and a user's re-seal of another
116
144
  // credential changes, so a token exchanged for one account never answers for the next
117
- const held = getActiveWorldStore().read(root.credential);
145
+ const store = getActiveWorldStore();
146
+ const held = store.read(root.credential);
118
147
  const fingerprint = held === null ? '' : String(JSON.parse(held).fingerprint ?? '');
119
- return { key: `${root.credential}#${fingerprint}`, open: () => openRootCredential(root, vendor), seal: (credential) => resealRootCredential(root, credential, vendor) };
148
+ let source = null;
149
+ return {
150
+ key: `${root.credential}#${fingerprint}`,
151
+ open: async () => {
152
+ // the exact record this exchange began from: the text the open read, or the text its own move wrote
153
+ const opened = await openRootRecord(root, vendor);
154
+ source = opened.text;
155
+ return opened.payload;
156
+ },
157
+ seal: (credential) => resealRootCredential(root, credential, vendor, source),
158
+ };
120
159
  }
121
160
  /** The vendor a root belongs to: its credential is sealed under the vendor's name. */
122
161
  export function vendorOf(root) { return basename(root.credential, '.json'); }
@@ -56,7 +56,7 @@ export { clearRoot, authStrategyFor, clearStateSystems, NO_SECRETS_CHECK, readRo
56
56
  export type { Check, CheckVerdict, DeployPolicy, RootConfig, StateSystemAdapters } from './state-system.js';
57
57
  export { observeAppends } from './actions.js';
58
58
  export { appendActionIfAbsent, appendActionOccurrence } from './actions.js';
59
- export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.js';
59
+ export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, destinationOf, openSealedRecord, sealsThroughVault, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.js';
60
60
  export type { BoundRoot, DeployReport, Head, PerformAction, PerformContext, PushOutcome } from './head.js';
61
61
  export { observeResource, observeResources, collectObservations, foldObservations } from './observe.js';
62
62
  export type { ObservedResource, ObserveReport, Observation } from './observe.js';
package/dist/src/index.js CHANGED
@@ -80,7 +80,7 @@ export { clearRoot, authStrategyFor, clearStateSystems, NO_SECRETS_CHECK, readRo
80
80
  export { observeAppends } from "./actions.js";
81
81
  export { appendActionIfAbsent, appendActionOccurrence } from "./actions.js";
82
82
  // PROTOCOL 2 — the head and the fold (company contract "The head", "Refresh is the kernel's fold")
83
- export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from "./head.js";
83
+ export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, destinationOf, openSealedRecord, sealsThroughVault, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from "./head.js";
84
84
  export { observeResource, observeResources, collectObservations, foldObservations } from "./observe.js";
85
85
  export { readTreeMap, readParentTreeMap, treeStamp, treeChangesSince, originEntries, appendOriginEntry, originLogPath, isUrlParent, parentPosition, positionAt, splitsBatch, assertBatchBoundary } from "./log.js";
86
86
  // v1 left the kernel (contract "Just like Neon", 5): the names a protocol 1 pack still imports throw on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "2.0.17",
3
+ "version": "2.0.18",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
package/src/credential.ts CHANGED
@@ -37,6 +37,10 @@ type SealedBody = {
37
37
  placedAt: string;
38
38
  fingerprint: string;
39
39
  rotatedAt?: string;
40
+ /** where the credential may be sent (`origin=https://…`), part of the AEAD's associated data: a record opens for
41
+ * its own destination and no other, so a root or link pointed elsewhere never carries it there (the executor also
42
+ * sends it to the hosts its vendor's pack declares: code, not configuration) */
43
+ bound?: string;
40
44
  };
41
45
  export type SealedByVault = SealedBody & {
42
46
  v: 2;
@@ -56,8 +60,22 @@ export type SealedUnderKey = {
56
60
  /** when a vendor last rotated a field of it (an OAuth refresh token): placedAt and fingerprint stay the credential's
57
61
  * identity across rotations, the ledgers keyed by the fingerprint with them */
58
62
  rotatedAt?: string;
63
+ /** as SealedBody's: the destination the credential is bound to, in its associated data */
64
+ bound?: string;
59
65
  };
60
66
 
67
+ /** The associated data a record is sealed under: its slot, and its destination when it has one. */
68
+ const associatedData = (slot: string, bound: string | undefined): Uint8Array<ArrayBuffer> => new Uint8Array(new TextEncoder().encode(bound === undefined ? slot : `${slot}\u0000${bound}`));
69
+
70
+ /** A record opened for a destination: one bound elsewhere refuses, and so does one bound nowhere (sealed before
71
+ * bindings, until bindLegacyCredentials binds it as its World wakes). With no destination asked (a webhook's signing
72
+ * secret, sent nowhere, or that binding itself), the record opens under the binding it carries. */
73
+ function checkBinding(sealed: { bound?: string }, bound: string | undefined): void {
74
+ if (bound === undefined) return;
75
+ if (sealed.bound === undefined) throw new Error(`credential sealed before it was bound to a destination: set it again for ${bound.replace(/^origin=/, '')}`);
76
+ if (sealed.bound !== bound) throw new Error(`credential bound to ${sealed.bound}, not ${bound}: set it again for this destination`);
77
+ }
78
+
61
79
  export interface CredentialStorage {
62
80
  getSealed(namespace: string, vendor: string): Promise<SealedCredential | null>;
63
81
  putSealed(namespace: string, vendor: string, sealed: SealedCredential): Promise<void>;
@@ -109,7 +127,7 @@ async function importKek(secret: string): Promise<CryptoKey> {
109
127
  return await crypto.subtle.importKey('raw', material, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
110
128
  }
111
129
 
112
- export async function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot = ''): Promise<SealedUnderKey> {
130
+ export async function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot = '', bound?: string): Promise<SealedUnderKey> {
113
131
  const kek = await importKek(kekSecret);
114
132
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
115
133
  const dekBytes = crypto.getRandomValues(new Uint8Array(32));
@@ -118,7 +136,7 @@ export async function sealCredential(kekSecret: string, payload: CredentialPaylo
118
136
  const dekIv = crypto.getRandomValues(new Uint8Array(12));
119
137
  // The SLOT ('{namespace}/{vendor}') rides as AAD (audit L16): a sealed record
120
138
  // transplanted to another slot by a bucket-level writer refuses to open there.
121
- const aad = new TextEncoder().encode(slot);
139
+ const aad = associatedData(slot, bound);
122
140
  const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad }, dek, plaintext));
123
141
  const wrappedDek = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dekIv }, kek, dekBytes));
124
142
  // KEYED fingerprint (review m1): an unsalted hash prefix is an offline oracle for
@@ -135,10 +153,12 @@ export async function sealCredential(kekSecret: string, payload: CredentialPaylo
135
153
  ciphertext: toB64(ciphertext),
136
154
  placedAt,
137
155
  fingerprint: [...digest.slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join(''),
156
+ ...(bound === undefined ? {} : { bound }),
138
157
  };
139
158
  }
140
159
 
141
- export async function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot = ''): Promise<CredentialPayload> {
160
+ export async function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot = '', bound?: string): Promise<CredentialPayload> {
161
+ checkBinding(sealed, bound);
142
162
  if (sealed.v === 2) throw new Error(`credential sealed by a vault (${sealed.transitKey}): open it with that vault's sealer`);
143
163
  const kek = await importKek(kekSecret);
144
164
  let dekBytes: ArrayBuffer;
@@ -149,7 +169,7 @@ export async function openSealedCredential(kekSecret: string, sealed: SealedCred
149
169
  }
150
170
  const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['decrypt']);
151
171
  try {
152
- const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: new TextEncoder().encode(slot) }, dek, fromB64(sealed.ciphertext)));
172
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: associatedData(slot, sealed.bound) }, dek, fromB64(sealed.ciphertext)));
153
173
  return JSON.parse(new TextDecoder().decode(plaintext)) as CredentialPayload;
154
174
  } catch {
155
175
  throw new Error('credential unseal failed: wrong KEK or corrupted record');
@@ -161,15 +181,17 @@ export async function openSealedCredential(kekSecret: string, sealed: SealedCred
161
181
  /** Seals and opens credentials. A host chooses one (`setSealer`); a key it holds (`keySealer`), or a vault that holds
162
182
  * the key and wraps for it (`transitSealer`). */
163
183
  export interface Sealer {
164
- seal(payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedCredential>;
165
- open(sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
184
+ /** `bound`: the destination the credential may be sent to, sealed into the record (see SealedBody) */
185
+ seal(payload: CredentialPayload, placedAt: string, slot?: string, bound?: string): Promise<SealedCredential>;
186
+ /** `bound`: the destination it is opened for; a record bound elsewhere refuses */
187
+ open(sealed: SealedCredential, slot?: string, bound?: string): Promise<CredentialPayload>;
166
188
  }
167
189
 
168
190
  /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
169
191
  export function keySealer(kekSecret: () => string): Sealer {
170
192
  return {
171
- seal: (payload, placedAt, slot = '') => sealCredential(kekSecret(), payload, placedAt, slot),
172
- open: (sealed, slot = '') => openSealedCredential(kekSecret(), sealed, slot),
193
+ seal: (payload, placedAt, slot = '', bound) => sealCredential(kekSecret(), payload, placedAt, slot, bound),
194
+ open: (sealed, slot = '', bound) => openSealedCredential(kekSecret(), sealed, slot, bound),
173
195
  };
174
196
  }
175
197
 
@@ -186,15 +208,18 @@ export type TransitSealerOptions = {
186
208
  /** opens `v: 1` records sealed before the host moved to the vault; seals nothing */
187
209
  keyForV1?: () => string;
188
210
  /** the transit key that keys every fingerprint (default `<key>-fingerprint`): one that is never rotated, so a
189
- * credential's fingerprint, and every ledger keyed by it, stays as it was while the wrapping key is rotated, its
190
- * min_encryption_version raised and its old versions trimmed (an hmac at a pinned version of a rotated key is
191
- * refused once that version is too old) */
211
+ * credential's fingerprint, and every ledger keyed by it, stays as it was while the wrapping key is rotated and its
212
+ * min_encryption_version raised (an hmac at a pinned version of a rotated key is refused once that version is too
213
+ * old). A wrapping key's old versions are never trimmed: a record's DEK stays under the version that wrapped it */
192
214
  fingerprintKey?: string;
193
215
  /** how long an opened DEK is kept in memory, ms (default 60 000): a burst of vendor calls asks the vault once */
194
216
  dekCacheMs?: number;
195
217
  /** how long a vault call may take, ms (default 10 000) */
196
218
  timeoutMs?: number;
197
219
  fetch?: typeof fetch;
220
+ /** renew the token (auth/token/renew-self) after calls, at most daily (default true); false where something else
221
+ * holds and renews it (the hosted Worlds' supervisor) */
222
+ renew?: boolean;
198
223
  };
199
224
 
200
225
  /**
@@ -206,13 +231,18 @@ export function transitSealer(options: TransitSealerOptions): Sealer {
206
231
  const mount = options.mount ?? 'transit';
207
232
  const named = `${mount}/${options.key}`;
208
233
  const addr = options.addr.replace(/\/+$/, '');
234
+ // the token, every DEK and every credential (hmac) go to this address: https, or http only to this machine
235
+ const parsed = new URL(addr);
236
+ if (parsed.username || parsed.password || !(parsed.protocol === 'https:' || (parsed.protocol === 'http:' && /^(127(\.\d{1,3}){3}|localhost|\[::1\])$/.test(parsed.hostname)))) {
237
+ throw new Error(`the vault address ${parsed.protocol}//${parsed.host} is neither https nor this machine`);
238
+ }
209
239
  const fingerprintKey = options.fingerprintKey ?? `${options.key}-fingerprint`;
210
240
  // a periodic token lives while it is renewed within its period: after a call it answered, at most once a day, the
211
241
  // sealer renews its own. A vault that refuses (a token that is not renewable, a root or dev token) is asked again the
212
242
  // next day; one that did not answer, after the next call. The token's own period is the bound.
213
243
  let renewedAt = 0;
214
244
  const renew = async (): Promise<void> => {
215
- if (Date.now() - renewedAt < 86_400_000) return;
245
+ if (options.renew === false || Date.now() - renewedAt < 86_400_000) return;
216
246
  renewedAt = Date.now();
217
247
  let status: number;
218
248
  try {
@@ -221,6 +251,8 @@ export function transitSealer(options: TransitSealerOptions): Sealer {
221
251
  headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
222
252
  body: '{}',
223
253
  signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
254
+ // never followed: a redirect would carry the token (and the DEK or credential) to wherever it points
255
+ redirect: 'manual',
224
256
  })).status;
225
257
  } catch (error) {
226
258
  renewedAt = 0;
@@ -239,10 +271,13 @@ export function transitSealer(options: TransitSealerOptions): Sealer {
239
271
  headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
240
272
  body: JSON.stringify(body),
241
273
  signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
274
+ // never followed: a redirect would carry the token (and the DEK or credential) to wherever it points
275
+ redirect: 'manual',
242
276
  });
243
277
  } catch (error) {
244
278
  throw new Error(`the vault at ${addr} did not answer transit ${op} with ${which} (${(error as Error).message})`);
245
279
  }
280
+ if (response.status >= 300 && response.status < 400) throw new Error(`the vault at ${addr} redirected transit ${op}: refused`);
246
281
  if (!response.ok) throw new Error(`the vault refused transit ${op} with ${which} (${response.status})`);
247
282
  const answer = (await response.json()) as { data?: Record<string, string> };
248
283
  if (!answer.data) throw new Error(`the vault answered transit ${op} with ${which} without data`);
@@ -268,25 +303,26 @@ export function transitSealer(options: TransitSealerOptions): Sealer {
268
303
  return dek;
269
304
  };
270
305
  return {
271
- async seal(payload, placedAt, slot = '') {
306
+ async seal(payload, placedAt, slot = '', bound) {
272
307
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
273
308
  const dekBytes = crypto.getRandomValues(new Uint8Array(32));
274
309
  const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['encrypt']);
275
310
  const iv = crypto.getRandomValues(new Uint8Array(12));
276
- const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(slot) }, dek, plaintext));
311
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: associatedData(slot, bound) }, dek, plaintext));
277
312
  const { ciphertext: wrappedDek } = await call('encrypt', { plaintext: toB64(dekBytes) });
278
313
  if (!wrappedDek) throw new Error(`the vault answered transit encrypt with ${named} without a ciphertext`);
279
- return { v: 2, transitKey: named, wrappedDek, iv: toB64(iv), ciphertext: toB64(ciphertext), placedAt, fingerprint: await fingerprintOf(plaintext) };
314
+ return { v: 2, transitKey: named, wrappedDek, iv: toB64(iv), ciphertext: toB64(ciphertext), placedAt, fingerprint: await fingerprintOf(plaintext), ...(bound === undefined ? {} : { bound }) };
280
315
  },
281
- async open(sealed, slot = '') {
316
+ async open(sealed, slot = '', bound) {
282
317
  if (sealed.v === 1) {
283
318
  if (!options.keyForV1) throw new Error('credential sealed under a key (v1), and this host has no key for v1 records');
284
- return openSealedCredential(options.keyForV1(), sealed, slot);
319
+ return openSealedCredential(options.keyForV1(), sealed, slot, bound);
285
320
  }
321
+ checkBinding(sealed, bound);
286
322
  if (sealed.transitKey !== named) throw new Error(`credential sealed by ${sealed.transitKey}, not ${named}`);
287
323
  const dek = await openedDek(sealed.wrappedDek);
288
324
  try {
289
- const out = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: new TextEncoder().encode(slot) }, dek, fromB64(sealed.ciphertext)));
325
+ const out = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(sealed.iv), additionalData: associatedData(slot, sealed.bound) }, dek, fromB64(sealed.ciphertext)));
290
326
  return JSON.parse(new TextDecoder().decode(out)) as CredentialPayload;
291
327
  } catch {
292
328
  throw new Error('credential unseal failed: wrong slot or corrupted record');
package/src/head.ts CHANGED
@@ -75,58 +75,98 @@ let hostSealer: Sealer | null = null;
75
75
  export function setSealer(sealer: Sealer | null): void { hostSealer = sealer; }
76
76
  export function currentSealer(): Sealer { return hostSealer ?? keySealer(sealingKey); }
77
77
 
78
+ /** The destination a credential is bound to (SealedBody.bound): the origin it is sent to, and only that. */
79
+ export function destinationOf(url: string): string { return `origin=${new URL(url).origin}`; }
80
+
78
81
  /**
79
- * Open the credential sealed at `path`, under the current sealer. A record the host's vault did not seal (`v: 1`, from
80
- * before the host moved to it) is sealed again by the vault as it opens, keeping its placedAt and fingerprint (its
81
- * identity, as a rotation keeps them), so the deployment's own key opens it once and never again (decision 0034).
82
+ * Open the credential sealed at `path`, under the current sealer, for `bound` (destinationOf the place it is about to
83
+ * be sent; omitted where it is sent nowhere, a webhook's signing secret). A record bound elsewhere, or bound nowhere,
84
+ * refuses. A record the host's vault did not seal (`v: 1`) is sealed again by the vault (decision 0034), keeping its
85
+ * binding, placedAt and fingerprint (its identity, as a rotation keeps them). The rewrite lands only over the record that was read; if another came meanwhile, that one is opened instead,
86
+ * so a credential replaced while this one moved is never handed out.
82
87
  */
83
- export async function openSealedAt(path: string, slot: string): Promise<CredentialPayload | null> {
88
+ export async function openSealedAt(path: string, slot: string, bound?: string): Promise<CredentialPayload | null> {
89
+ return (await openSealedRecord(path, slot, bound))?.payload ?? null;
90
+ }
91
+
92
+ /** Whether this host seals through a vault (setSealer): what a record under the host's key is moved to. */
93
+ export function sealsThroughVault(): boolean { return hostSealer !== null; }
94
+
95
+ /** openSealedAt, with the exact record it answers from: the text it read, or the text its own move wrote. A rotation
96
+ * lands only over that text (rootCustody), so it never mistakes another record for the one it opened. */
97
+ export async function openSealedRecord(path: string, slot: string, bound?: string, again = true): Promise<{ payload: CredentialPayload; text: string } | null> {
84
98
  const store = getActiveWorldStore();
85
99
  const text = store.read(path);
86
100
  if (text === null) return null;
87
101
  const sealed = JSON.parse(text) as SealedCredential;
88
- const payload = await currentSealer().open(sealed, slot);
89
- if (hostSealer && sealed.v === 1) {
102
+ const payload = await currentSealer().open(sealed, slot, bound);
103
+ const move = hostSealer !== null && sealed.v === 1;
104
+ let wrote: string | null = null;
105
+ if (move) {
90
106
  try {
91
- const moved = await hostSealer.seal(payload, sealed.placedAt, slot);
92
- // only over the record that was read: a credential set or rotated meanwhile stands (read and write are
93
- // synchronous, so nothing comes between this check and the write)
94
- if (store.read(path) === text) store.write(path, `${JSON.stringify({ ...moved, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`, { secret: true });
107
+ const resealed = await currentSealer().seal(payload, sealed.placedAt, slot, sealed.bound);
108
+ // read and write are synchronous, so nothing comes between this check and the write
109
+ if (store.read(path) === text) {
110
+ const moved = `${JSON.stringify({ ...resealed, fingerprint: sealed.fingerprint, ...(sealed.rotatedAt ? { rotatedAt: sealed.rotatedAt } : {}) }, null, 2)}\n`;
111
+ store.write(path, moved, { secret: true });
112
+ wrote = moved;
113
+ }
95
114
  } catch (error) {
96
- // the credential opened: a move that fails (the vault down, a token expired) waits for the next open
115
+ // the credential opened: a rewrite that fails (the vault down, a token expired) waits for the next open
97
116
  console.warn(`[credential] ${slot}: not moved to the vault yet (${(error as Error).message})`);
98
117
  }
99
118
  }
100
- return payload;
119
+ const answeredFrom = wrote ?? text;
120
+ if (store.read(path) !== answeredFrom) return again ? openSealedRecord(path, slot, bound, false) : null;
121
+ return { payload, text: answeredFrom };
101
122
  }
102
123
 
103
124
  /** A root as a world materializes it: the config plus the path of the sealed credential. */
104
125
  export type BoundRoot = RootConfig & { credential: string };
105
126
 
106
- /** Open the credential a root names, under the current sealer. */
127
+ /** Open the credential a root names, under the current sealer, for the root's own origin. */
107
128
  export async function openRootCredential(root: BoundRoot, vendor: string = vendorOf(root)): Promise<CredentialPayload> {
108
- const payload = await openSealedAt(root.credential, vendor);
109
- if (payload === null) throw new Error(`no credential sealed for ${vendor} — \`printf '<token>' | volter twin ${vendor} credential\``);
110
- return payload;
129
+ return (await openRootRecord(root, vendor)).payload;
111
130
  }
112
- /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
113
- * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
114
- * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. */
115
- export async function resealRootCredential(root: BoundRoot, payload: CredentialPayload, vendor: string = vendorOf(root)): Promise<void> {
131
+ async function openRootRecord(root: BoundRoot, vendor: string): Promise<{ payload: CredentialPayload; text: string }> {
132
+ const opened = await openSealedRecord(root.credential, vendor, destinationOf(root.url));
133
+ if (opened === null) throw new Error(`no credential sealed for ${vendor} — \`printf '<token>' | volter twin ${vendor} credential\``);
134
+ return opened;
135
+ }
136
+ /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key, slot and
137
+ * binding. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
138
+ * fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when. `source` is the record
139
+ * the rotation began from: if another credential was set meanwhile, the rotation is dropped, never written over it. */
140
+ export async function resealRootCredential(root: BoundRoot, payload: CredentialPayload, vendor: string = vendorOf(root), source?: string | null): Promise<void> {
116
141
  const store = getActiveWorldStore();
117
142
  const held = store.read(root.credential);
143
+ if (source !== undefined && held !== source) throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
118
144
  const before = held === null ? null : (JSON.parse(held) as SealedCredential);
119
145
  const now = new Date().toISOString();
120
- const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor);
146
+ const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor, destinationOf(root.url));
147
+ // read and write are synchronous: nothing comes between this check and the write
148
+ if (store.read(root.credential) !== held) throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
121
149
  store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
122
150
  }
123
- /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one. */
151
+ /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one, which
152
+ * lands only over the record that was opened. */
124
153
  export function rootCustody(root: BoundRoot, vendor: string = vendorOf(root)): CredentialCustody {
125
154
  // the credential's identity: its path and its fingerprint, which a rotation keeps and a user's re-seal of another
126
155
  // credential changes, so a token exchanged for one account never answers for the next
127
- const held = getActiveWorldStore().read(root.credential);
156
+ const store = getActiveWorldStore();
157
+ const held = store.read(root.credential);
128
158
  const fingerprint = held === null ? '' : String((JSON.parse(held) as SealedCredential).fingerprint ?? '');
129
- return { key: `${root.credential}#${fingerprint}`, open: () => openRootCredential(root, vendor), seal: (credential) => resealRootCredential(root, credential, vendor) };
159
+ let source: string | null = null;
160
+ return {
161
+ key: `${root.credential}#${fingerprint}`,
162
+ open: async () => {
163
+ // the exact record this exchange began from: the text the open read, or the text its own move wrote
164
+ const opened = await openRootRecord(root, vendor);
165
+ source = opened.text;
166
+ return opened.payload;
167
+ },
168
+ seal: (credential) => resealRootCredential(root, credential, vendor, source),
169
+ };
130
170
  }
131
171
  /** The vendor a root belongs to: its credential is sealed under the vendor's name. */
132
172
  export function vendorOf(root: BoundRoot): string { return basename(root.credential, '.json'); }
package/src/index.ts CHANGED
@@ -302,7 +302,7 @@ export { observeAppends } from './actions.ts';
302
302
  export { appendActionIfAbsent, appendActionOccurrence } from './actions.ts';
303
303
 
304
304
  // PROTOCOL 2 — the head and the fold (company contract "The head", "Refresh is the kernel's fold")
305
- export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.ts';
305
+ export { answerVendorErrors, performEntries, performAtHead, deployableEntries, headOf, boundRoot, loadChecks, loadCheck, openRootCredential, resealRootCredential, destinationOf, openSealedRecord, sealsThroughVault, rootCustody, sealingKey, setCheckLoader, setSealingKeySource, setSealer, currentSealer, openSealedAt, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.ts';
306
306
  export type { BoundRoot, DeployReport, Head, PerformAction, PerformContext, PushOutcome } from './head.ts';
307
307
  export { observeResource, observeResources, collectObservations, foldObservations } from './observe.ts';
308
308
  export type { ObservedResource, ObserveReport, Observation } from './observe.ts';