@volter/world-core 2.0.15 → 2.0.17

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.
@@ -3,10 +3,26 @@ export type CredentialPayload = {
3
3
  secret?: string;
4
4
  keyId?: string;
5
5
  } & Record<string, unknown>;
6
- /** The at-rest form. All byte fields base64. The fingerprint is a short SHA-256 prefix
6
+ /** The at-rest form. All byte fields base64. The fingerprint is a short keyed hash (HMAC)
7
7
  * of the payload — enough for an operator to recognize "the key ending a1b2…", never
8
- * enough to recover anything. */
9
- export type SealedCredential = {
8
+ * enough to recover anything. `v: 1` wraps the DEK under a key the host holds; `v: 2` has a
9
+ * vault's transit engine wrap it (company decision 0034), naming the transit key that did. */
10
+ export type SealedCredential = SealedUnderKey | SealedByVault;
11
+ type SealedBody = {
12
+ iv: string;
13
+ ciphertext: string;
14
+ placedAt: string;
15
+ fingerprint: string;
16
+ rotatedAt?: string;
17
+ };
18
+ export type SealedByVault = SealedBody & {
19
+ v: 2;
20
+ /** `<mount>/<key>` of the transit key that wrapped the DEK */
21
+ transitKey: string;
22
+ /** the vault's own ciphertext of the DEK (`vault:vN:…`) */
23
+ wrappedDek: string;
24
+ };
25
+ export type SealedUnderKey = {
10
26
  v: 1;
11
27
  dekIv: string;
12
28
  wrappedDek: string;
@@ -34,5 +50,43 @@ export declare class MemoryCredentialStorage implements CredentialStorage {
34
50
  deleteSealed(namespace: string, vendor: string): Promise<void>;
35
51
  deleteNamespace(namespace: string): Promise<void>;
36
52
  }
37
- export declare function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedCredential>;
53
+ export declare function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedUnderKey>;
38
54
  export declare function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
55
+ /** Seals and opens credentials. A host chooses one (`setSealer`); a key it holds (`keySealer`), or a vault that holds
56
+ * the key and wraps for it (`transitSealer`). */
57
+ export interface Sealer {
58
+ seal(payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedCredential>;
59
+ open(sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
60
+ }
61
+ /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
62
+ export declare function keySealer(kekSecret: () => string): Sealer;
63
+ export type TransitSealerOptions = {
64
+ /** the vault's address (`https://…`, or `http://` to loopback) */
65
+ addr: string;
66
+ /** a token that may encrypt, decrypt and hmac with the key */
67
+ token: () => string | Promise<string>;
68
+ /** the transit key's name */
69
+ key: string;
70
+ /** the transit engine's mount (default `transit`) */
71
+ mount?: string;
72
+ namespace?: string;
73
+ /** opens `v: 1` records sealed before the host moved to the vault; seals nothing */
74
+ keyForV1?: () => string;
75
+ /** 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) */
79
+ fingerprintKey?: string;
80
+ /** how long an opened DEK is kept in memory, ms (default 60 000): a burst of vendor calls asks the vault once */
81
+ dekCacheMs?: number;
82
+ /** how long a vault call may take, ms (default 10 000) */
83
+ timeoutMs?: number;
84
+ fetch?: typeof fetch;
85
+ };
86
+ /**
87
+ * OpenBao's (or Vault's) transit engine wraps each credential's DEK: the key never leaves the vault. The payload is
88
+ * sealed as `v: 1` seals it (a random DEK, AES-GCM, the slot as AAD); the vault encrypts the DEK and keys the
89
+ * fingerprint (`transit/hmac`). `v: 2` records name `<mount>/<key>`; a record from another key refuses.
90
+ */
91
+ export declare function transitSealer(options: TransitSealerOptions): Sealer;
92
+ export {};
@@ -95,6 +95,8 @@ export async function sealCredential(kekSecret, payload, placedAt, slot = '') {
95
95
  };
96
96
  }
97
97
  export async function openSealedCredential(kekSecret, sealed, slot = '') {
98
+ if (sealed.v === 2)
99
+ throw new Error(`credential sealed by a vault (${sealed.transitKey}): open it with that vault's sealer`);
98
100
  const kek = await importKek(kekSecret);
99
101
  let dekBytes;
100
102
  try {
@@ -112,3 +114,121 @@ export async function openSealedCredential(kekSecret, sealed, slot = '') {
112
114
  throw new Error('credential unseal failed: wrong KEK or corrupted record');
113
115
  }
114
116
  }
117
+ /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
118
+ export function keySealer(kekSecret) {
119
+ return {
120
+ seal: (payload, placedAt, slot = '') => sealCredential(kekSecret(), payload, placedAt, slot),
121
+ open: (sealed, slot = '') => openSealedCredential(kekSecret(), sealed, slot),
122
+ };
123
+ }
124
+ /**
125
+ * OpenBao's (or Vault's) transit engine wraps each credential's DEK: the key never leaves the vault. The payload is
126
+ * sealed as `v: 1` seals it (a random DEK, AES-GCM, the slot as AAD); the vault encrypts the DEK and keys the
127
+ * fingerprint (`transit/hmac`). `v: 2` records name `<mount>/<key>`; a record from another key refuses.
128
+ */
129
+ export function transitSealer(options) {
130
+ const mount = options.mount ?? 'transit';
131
+ const named = `${mount}/${options.key}`;
132
+ const addr = options.addr.replace(/\/+$/, '');
133
+ const fingerprintKey = options.fingerprintKey ?? `${options.key}-fingerprint`;
134
+ // a periodic token lives while it is renewed within its period: after a call it answered, at most once a day, the
135
+ // sealer renews its own. A vault that refuses (a token that is not renewable, a root or dev token) is asked again the
136
+ // next day; one that did not answer, after the next call. The token's own period is the bound.
137
+ let renewedAt = 0;
138
+ const renew = async () => {
139
+ if (Date.now() - renewedAt < 86_400_000)
140
+ return;
141
+ renewedAt = Date.now();
142
+ let status;
143
+ try {
144
+ status = (await (options.fetch ?? fetch)(`${addr}/v1/auth/token/renew-self`, {
145
+ method: 'POST',
146
+ headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
147
+ body: '{}',
148
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
149
+ })).status;
150
+ }
151
+ catch (error) {
152
+ renewedAt = 0;
153
+ console.warn(`[credential] the vault at ${addr} did not answer renewing this host's token (${error.message})`);
154
+ return;
155
+ }
156
+ if (status >= 500)
157
+ renewedAt = 0;
158
+ if (status >= 300)
159
+ console.warn(`[credential] the vault at ${addr} did not renew this host's token (${status})`);
160
+ };
161
+ const call = async (op, body, key = options.key) => {
162
+ const which = `${mount}/${key}`;
163
+ let response;
164
+ try {
165
+ response = await (options.fetch ?? fetch)(`${addr}/v1/${mount}/${op}/${encodeURIComponent(key)}`, {
166
+ method: 'POST',
167
+ headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
168
+ body: JSON.stringify(body),
169
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
170
+ });
171
+ }
172
+ catch (error) {
173
+ throw new Error(`the vault at ${addr} did not answer transit ${op} with ${which} (${error.message})`);
174
+ }
175
+ if (!response.ok)
176
+ throw new Error(`the vault refused transit ${op} with ${which} (${response.status})`);
177
+ const answer = (await response.json());
178
+ if (!answer.data)
179
+ throw new Error(`the vault answered transit ${op} with ${which} without data`);
180
+ void renew();
181
+ return answer.data;
182
+ };
183
+ const fingerprintOf = async (plaintext) => {
184
+ const { hmac } = await call('hmac', { input: toB64(plaintext) }, fingerprintKey);
185
+ const digest = fromB64(String(hmac).replace(/^vault:v\d+:/, ''));
186
+ return [...digest.slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
187
+ };
188
+ // an opened DEK, briefly: the vault's ciphertext names it, so a re-sealed record is a new entry
189
+ const opened = new Map();
190
+ const openedDek = (wrappedDek) => {
191
+ const now = Date.now();
192
+ for (const [k, v] of opened)
193
+ if (v.until <= now)
194
+ opened.delete(k);
195
+ const held = opened.get(wrappedDek);
196
+ if (held)
197
+ return held.dek;
198
+ const dek = call('decrypt', { ciphertext: wrappedDek })
199
+ .then(({ plaintext }) => crypto.subtle.importKey('raw', fromB64(String(plaintext)), { name: 'AES-GCM' }, false, ['decrypt']));
200
+ dek.catch(() => opened.delete(wrappedDek));
201
+ opened.set(wrappedDek, { dek, until: now + (options.dekCacheMs ?? 60_000) });
202
+ return dek;
203
+ };
204
+ return {
205
+ async seal(payload, placedAt, slot = '') {
206
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
207
+ const dekBytes = crypto.getRandomValues(new Uint8Array(32));
208
+ const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['encrypt']);
209
+ 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));
211
+ const { ciphertext: wrappedDek } = await call('encrypt', { plaintext: toB64(dekBytes) });
212
+ if (!wrappedDek)
213
+ 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) };
215
+ },
216
+ async open(sealed, slot = '') {
217
+ if (sealed.v === 1) {
218
+ if (!options.keyForV1)
219
+ 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);
221
+ }
222
+ if (sealed.transitKey !== named)
223
+ throw new Error(`credential sealed by ${sealed.transitKey}, not ${named}`);
224
+ const dek = await openedDek(sealed.wrappedDek);
225
+ 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)));
227
+ return JSON.parse(new TextDecoder().decode(out));
228
+ }
229
+ catch {
230
+ throw new Error('credential unseal failed: wrong slot or corrupted record');
231
+ }
232
+ },
233
+ };
234
+ }
@@ -1,5 +1,5 @@
1
1
  import { type TwinAction } from './actions.js';
2
- import { type CredentialPayload } from './credential.js';
2
+ import { type CredentialPayload, type Sealer } from './credential.js';
3
3
  import { type CredentialCustody } from './executor.js';
4
4
  import type { RemoteExecute } from './remote-execute.js';
5
5
  import { type Check, type DeployPolicy, type RootConfig, type StateSystemAdapters } from './state-system.js';
@@ -12,7 +12,8 @@ export type PushOutcome = {
12
12
  };
13
13
  /** What a perform is handed beside the executor: a resolver from a local id to the vendor's (an
14
14
  * entry authored against `twin-1` crosses against `REAL-42` once that subject's landing adopted it). */
15
- /** `credential`: the sealed credential's keyed fingerprint (credential.ts — an HMAC under the user's key,
15
+ /** `credential`: the sealed credential's keyed fingerprint (credential.ts — an HMAC under the sealing key, or the
16
+ * vault's transit hmac at a pinned key version,
16
17
  * never the secret and no offline oracle for it), so a pack keys its budget ledger per real credential:
17
18
  * every World and branch performing with one vendor key then shares that key's one allowance. */
18
19
  export type PerformContext = {
@@ -28,11 +29,19 @@ export declare function userKekPath(): string;
28
29
  export declare function userKek(): string;
29
30
  export declare function setSealingKeySource(source: (() => string) | null): void;
30
31
  export declare function sealingKey(): string;
32
+ export declare function setSealer(sealer: Sealer | null): void;
33
+ export declare function currentSealer(): Sealer;
34
+ /**
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).
38
+ */
39
+ export declare function openSealedAt(path: string, slot: string): Promise<CredentialPayload | null>;
31
40
  /** A root as a world materializes it: the config plus the path of the sealed credential. */
32
41
  export type BoundRoot = RootConfig & {
33
42
  credential: string;
34
43
  };
35
- /** Open the credential a root names, under the user's key. */
44
+ /** Open the credential a root names, under the current sealer. */
36
45
  export declare function openRootCredential(root: BoundRoot, vendor?: string): Promise<CredentialPayload>;
37
46
  /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
38
47
  * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
package/dist/src/head.js CHANGED
@@ -19,7 +19,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
19
19
  import { homedir } from 'node:os';
20
20
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
21
21
  import { confirmAction, isTwinBookkeeping, resolveSubjectId, revertAction } from "./actions.js";
22
- import { openSealedCredential, sealCredential } from "./credential.js";
22
+ import { keySealer } from "./credential.js";
23
23
  import { buildRemoteExecute, validateRemoteOrigin } from "./executor.js";
24
24
  import { getPack } from "./packRegistry.js";
25
25
  import { aliasesFrom, branchEntries, parentEntries, readTree } from "./log.js";
@@ -60,12 +60,44 @@ export function userKek() {
60
60
  let sealingKeySource = null;
61
61
  export function setSealingKeySource(source) { sealingKeySource = source; }
62
62
  export function sealingKey() { return sealingKeySource ? sealingKeySource() : userKek(); }
63
- /** Open the credential a root names, under the user's key. */
63
+ /** What seals and opens this process's credentials: the host's sealer (a vault's transit engine, company decision
64
+ * 0034), else a key sealer over `sealingKey()`. */
65
+ let hostSealer = null;
66
+ export function setSealer(sealer) { hostSealer = sealer; }
67
+ export function currentSealer() { return hostSealer ?? keySealer(sealingKey); }
68
+ /**
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).
72
+ */
73
+ export async function openSealedAt(path, slot) {
74
+ const store = getActiveWorldStore();
75
+ const text = store.read(path);
76
+ if (text === null)
77
+ return null;
78
+ const sealed = JSON.parse(text);
79
+ const payload = await currentSealer().open(sealed, slot);
80
+ if (hostSealer && sealed.v === 1) {
81
+ 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 });
87
+ }
88
+ catch (error) {
89
+ // the credential opened: a move that fails (the vault down, a token expired) waits for the next open
90
+ console.warn(`[credential] ${slot}: not moved to the vault yet (${error.message})`);
91
+ }
92
+ }
93
+ return payload;
94
+ }
95
+ /** Open the credential a root names, under the current sealer. */
64
96
  export async function openRootCredential(root, vendor = vendorOf(root)) {
65
- const sealed = getActiveWorldStore().read(root.credential);
66
- if (sealed === null)
97
+ const payload = await openSealedAt(root.credential, vendor);
98
+ if (payload === null)
67
99
  throw new Error(`no credential sealed for ${vendor} — \`printf '<token>' | volter twin ${vendor} credential\``);
68
- return openSealedCredential(sealingKey(), JSON.parse(sealed), vendor);
100
+ return payload;
69
101
  }
70
102
  /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
71
103
  * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
@@ -75,7 +107,7 @@ export async function resealRootCredential(root, payload, vendor = vendorOf(root
75
107
  const held = store.read(root.credential);
76
108
  const before = held === null ? null : JSON.parse(held);
77
109
  const now = new Date().toISOString();
78
- const sealed = await sealCredential(sealingKey(), payload, before?.placedAt ?? now, vendor);
110
+ const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor);
79
111
  store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
80
112
  }
81
113
  /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one. */
@@ -48,7 +48,7 @@ export * as git from './git/index.js';
48
48
  export { PLACEHOLDER_REMOTE, beginPlaceholderPull, endPlaceholderPull, placeholderPullActive, placeholderPullMarkerPath, withPlaceholderPull, placeholderEventsFor } from './placeholder-remote.js';
49
49
  export { ownFields, subjectHistory, rebaseBranch, cutCheckpoint, appendParentEntry, wholeLog, branchEntries, branchLogPath, branchMetaPath, CHECKPOINT_EVERY, dropCheckpoint, foldEntries, landedCopy, landedIds, parentEntries, parentLogPath, position, readBranchMeta, readTree, toEntry, toEvent, unpushedEntries, writeBranchMeta } from './log.js';
50
50
  export type { BranchMeta, Entry, EntryOp, Receipt } from './log.js';
51
- export { sealCredential, openSealedCredential, MemoryCredentialStorage } from './credential.js';
51
+ export { sealCredential, openSealedCredential, MemoryCredentialStorage, keySealer, transitSealer, type Sealer, type TransitSealerOptions, type SealedByVault, type SealedUnderKey } from './credential.js';
52
52
  export type { CredentialPayload, CredentialStorage, SealedCredential } from './credential.js';
53
53
  export { validateRemoteOrigin, buildRemoteExecute, type CredentialCustody } from './executor.js';
54
54
  export type { TwinAuthStrategy } from './executor.js';
@@ -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, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.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';
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
@@ -74,13 +74,13 @@ export * as git from "./git/index.js";
74
74
  // The placeholder remote (contract section of that name): the seed is a pull, never a pending write.
75
75
  export { PLACEHOLDER_REMOTE, beginPlaceholderPull, endPlaceholderPull, placeholderPullActive, placeholderPullMarkerPath, withPlaceholderPull, placeholderEventsFor } from "./placeholder-remote.js";
76
76
  export { ownFields, subjectHistory, rebaseBranch, cutCheckpoint, appendParentEntry, wholeLog, branchEntries, branchLogPath, branchMetaPath, CHECKPOINT_EVERY, dropCheckpoint, foldEntries, landedCopy, landedIds, parentEntries, parentLogPath, position, readBranchMeta, readTree, toEntry, toEvent, unpushedEntries, writeBranchMeta } from "./log.js";
77
- export { sealCredential, openSealedCredential, MemoryCredentialStorage } from "./credential.js";
77
+ export { sealCredential, openSealedCredential, MemoryCredentialStorage, keySealer, transitSealer } from "./credential.js";
78
78
  export { validateRemoteOrigin, buildRemoteExecute } from "./executor.js";
79
79
  export { clearRoot, authStrategyFor, clearStateSystems, NO_SECRETS_CHECK, readRoot, registerAuthStrategy, registerStateSystem, rootPath, runChecks, stateSystemFor, writeRoot } from "./state-system.js";
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, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from "./head.js";
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";
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.15",
3
+ "version": "2.0.17",
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
@@ -26,10 +26,26 @@
26
26
 
27
27
  export type CredentialPayload = { headers: Record<string, string>; secret?: string; keyId?: string } & Record<string, unknown>;
28
28
 
29
- /** The at-rest form. All byte fields base64. The fingerprint is a short SHA-256 prefix
29
+ /** The at-rest form. All byte fields base64. The fingerprint is a short keyed hash (HMAC)
30
30
  * of the payload — enough for an operator to recognize "the key ending a1b2…", never
31
- * enough to recover anything. */
32
- export type SealedCredential = {
31
+ * enough to recover anything. `v: 1` wraps the DEK under a key the host holds; `v: 2` has a
32
+ * vault's transit engine wrap it (company decision 0034), naming the transit key that did. */
33
+ export type SealedCredential = SealedUnderKey | SealedByVault;
34
+ type SealedBody = {
35
+ iv: string;
36
+ ciphertext: string;
37
+ placedAt: string;
38
+ fingerprint: string;
39
+ rotatedAt?: string;
40
+ };
41
+ export type SealedByVault = SealedBody & {
42
+ v: 2;
43
+ /** `<mount>/<key>` of the transit key that wrapped the DEK */
44
+ transitKey: string;
45
+ /** the vault's own ciphertext of the DEK (`vault:vN:…`) */
46
+ wrappedDek: string;
47
+ };
48
+ export type SealedUnderKey = {
33
49
  v: 1;
34
50
  dekIv: string;
35
51
  wrappedDek: string;
@@ -93,7 +109,7 @@ async function importKek(secret: string): Promise<CryptoKey> {
93
109
  return await crypto.subtle.importKey('raw', material, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
94
110
  }
95
111
 
96
- export async function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot = ''): Promise<SealedCredential> {
112
+ export async function sealCredential(kekSecret: string, payload: CredentialPayload, placedAt: string, slot = ''): Promise<SealedUnderKey> {
97
113
  const kek = await importKek(kekSecret);
98
114
  const plaintext = new TextEncoder().encode(JSON.stringify(payload));
99
115
  const dekBytes = crypto.getRandomValues(new Uint8Array(32));
@@ -123,6 +139,7 @@ export async function sealCredential(kekSecret: string, payload: CredentialPaylo
123
139
  }
124
140
 
125
141
  export async function openSealedCredential(kekSecret: string, sealed: SealedCredential, slot = ''): Promise<CredentialPayload> {
142
+ if (sealed.v === 2) throw new Error(`credential sealed by a vault (${sealed.transitKey}): open it with that vault's sealer`);
126
143
  const kek = await importKek(kekSecret);
127
144
  let dekBytes: ArrayBuffer;
128
145
  try {
@@ -138,3 +155,142 @@ export async function openSealedCredential(kekSecret: string, sealed: SealedCred
138
155
  throw new Error('credential unseal failed: wrong KEK or corrupted record');
139
156
  }
140
157
  }
158
+
159
+ // ── a sealer: what seals and opens a host's credentials ─────────────────────────────────────
160
+
161
+ /** Seals and opens credentials. A host chooses one (`setSealer`); a key it holds (`keySealer`), or a vault that holds
162
+ * the key and wraps for it (`transitSealer`). */
163
+ export interface Sealer {
164
+ seal(payload: CredentialPayload, placedAt: string, slot?: string): Promise<SealedCredential>;
165
+ open(sealed: SealedCredential, slot?: string): Promise<CredentialPayload>;
166
+ }
167
+
168
+ /** Wraps under a key the host holds (the user's key file, a deployment secret): `v: 1` records. */
169
+ export function keySealer(kekSecret: () => string): Sealer {
170
+ return {
171
+ seal: (payload, placedAt, slot = '') => sealCredential(kekSecret(), payload, placedAt, slot),
172
+ open: (sealed, slot = '') => openSealedCredential(kekSecret(), sealed, slot),
173
+ };
174
+ }
175
+
176
+ export type TransitSealerOptions = {
177
+ /** the vault's address (`https://…`, or `http://` to loopback) */
178
+ addr: string;
179
+ /** a token that may encrypt, decrypt and hmac with the key */
180
+ token: () => string | Promise<string>;
181
+ /** the transit key's name */
182
+ key: string;
183
+ /** the transit engine's mount (default `transit`) */
184
+ mount?: string;
185
+ namespace?: string;
186
+ /** opens `v: 1` records sealed before the host moved to the vault; seals nothing */
187
+ keyForV1?: () => string;
188
+ /** 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) */
192
+ fingerprintKey?: string;
193
+ /** how long an opened DEK is kept in memory, ms (default 60 000): a burst of vendor calls asks the vault once */
194
+ dekCacheMs?: number;
195
+ /** how long a vault call may take, ms (default 10 000) */
196
+ timeoutMs?: number;
197
+ fetch?: typeof fetch;
198
+ };
199
+
200
+ /**
201
+ * OpenBao's (or Vault's) transit engine wraps each credential's DEK: the key never leaves the vault. The payload is
202
+ * sealed as `v: 1` seals it (a random DEK, AES-GCM, the slot as AAD); the vault encrypts the DEK and keys the
203
+ * fingerprint (`transit/hmac`). `v: 2` records name `<mount>/<key>`; a record from another key refuses.
204
+ */
205
+ export function transitSealer(options: TransitSealerOptions): Sealer {
206
+ const mount = options.mount ?? 'transit';
207
+ const named = `${mount}/${options.key}`;
208
+ const addr = options.addr.replace(/\/+$/, '');
209
+ const fingerprintKey = options.fingerprintKey ?? `${options.key}-fingerprint`;
210
+ // a periodic token lives while it is renewed within its period: after a call it answered, at most once a day, the
211
+ // sealer renews its own. A vault that refuses (a token that is not renewable, a root or dev token) is asked again the
212
+ // next day; one that did not answer, after the next call. The token's own period is the bound.
213
+ let renewedAt = 0;
214
+ const renew = async (): Promise<void> => {
215
+ if (Date.now() - renewedAt < 86_400_000) return;
216
+ renewedAt = Date.now();
217
+ let status: number;
218
+ try {
219
+ status = (await (options.fetch ?? fetch)(`${addr}/v1/auth/token/renew-self`, {
220
+ method: 'POST',
221
+ headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
222
+ body: '{}',
223
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
224
+ })).status;
225
+ } catch (error) {
226
+ renewedAt = 0;
227
+ console.warn(`[credential] the vault at ${addr} did not answer renewing this host's token (${(error as Error).message})`);
228
+ return;
229
+ }
230
+ if (status >= 500) renewedAt = 0;
231
+ if (status >= 300) console.warn(`[credential] the vault at ${addr} did not renew this host's token (${status})`);
232
+ };
233
+ const call = async (op: 'encrypt' | 'decrypt' | 'hmac', body: Record<string, string | number>, key = options.key): Promise<Record<string, string>> => {
234
+ const which = `${mount}/${key}`;
235
+ let response: Response;
236
+ try {
237
+ response = await (options.fetch ?? fetch)(`${addr}/v1/${mount}/${op}/${encodeURIComponent(key)}`, {
238
+ method: 'POST',
239
+ headers: { 'x-vault-token': await options.token(), 'content-type': 'application/json', ...(options.namespace ? { 'x-vault-namespace': options.namespace } : {}) },
240
+ body: JSON.stringify(body),
241
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
242
+ });
243
+ } catch (error) {
244
+ throw new Error(`the vault at ${addr} did not answer transit ${op} with ${which} (${(error as Error).message})`);
245
+ }
246
+ if (!response.ok) throw new Error(`the vault refused transit ${op} with ${which} (${response.status})`);
247
+ const answer = (await response.json()) as { data?: Record<string, string> };
248
+ if (!answer.data) throw new Error(`the vault answered transit ${op} with ${which} without data`);
249
+ void renew();
250
+ return answer.data;
251
+ };
252
+ const fingerprintOf = async (plaintext: Uint8Array): Promise<string> => {
253
+ const { hmac } = await call('hmac', { input: toB64(plaintext) }, fingerprintKey);
254
+ const digest = fromB64(String(hmac).replace(/^vault:v\d+:/, ''));
255
+ return [...digest.slice(0, 6)].map((b) => b.toString(16).padStart(2, '0')).join('');
256
+ };
257
+ // an opened DEK, briefly: the vault's ciphertext names it, so a re-sealed record is a new entry
258
+ const opened = new Map<string, { dek: Promise<CryptoKey>; until: number }>();
259
+ const openedDek = (wrappedDek: string): Promise<CryptoKey> => {
260
+ const now = Date.now();
261
+ for (const [k, v] of opened) if (v.until <= now) opened.delete(k);
262
+ const held = opened.get(wrappedDek);
263
+ if (held) return held.dek;
264
+ const dek = call('decrypt', { ciphertext: wrappedDek })
265
+ .then(({ plaintext }) => crypto.subtle.importKey('raw', fromB64(String(plaintext)), { name: 'AES-GCM' }, false, ['decrypt']));
266
+ dek.catch(() => opened.delete(wrappedDek));
267
+ opened.set(wrappedDek, { dek, until: now + (options.dekCacheMs ?? 60_000) });
268
+ return dek;
269
+ };
270
+ return {
271
+ async seal(payload, placedAt, slot = '') {
272
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
273
+ const dekBytes = crypto.getRandomValues(new Uint8Array(32));
274
+ const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['encrypt']);
275
+ 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));
277
+ const { ciphertext: wrappedDek } = await call('encrypt', { plaintext: toB64(dekBytes) });
278
+ 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) };
280
+ },
281
+ async open(sealed, slot = '') {
282
+ if (sealed.v === 1) {
283
+ 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);
285
+ }
286
+ if (sealed.transitKey !== named) throw new Error(`credential sealed by ${sealed.transitKey}, not ${named}`);
287
+ const dek = await openedDek(sealed.wrappedDek);
288
+ 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)));
290
+ return JSON.parse(new TextDecoder().decode(out)) as CredentialPayload;
291
+ } catch {
292
+ throw new Error('credential unseal failed: wrong slot or corrupted record');
293
+ }
294
+ },
295
+ };
296
+ }
package/src/head.ts CHANGED
@@ -11,7 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
11
11
  import { homedir } from 'node:os';
12
12
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
13
13
  import { confirmAction, isTwinBookkeeping, resolveSubjectId, revertAction, type TwinAction } from './actions.ts';
14
- import { openSealedCredential, sealCredential, type CredentialPayload, type SealedCredential } from './credential.ts';
14
+ import { keySealer, type CredentialPayload, type SealedCredential, type Sealer } from './credential.ts';
15
15
  import { buildRemoteExecute, validateRemoteOrigin, type CredentialCustody } from './executor.ts';
16
16
  import { getPack } from './packRegistry.ts';
17
17
  import type { RemoteExecute } from './remote-execute.ts';
@@ -29,7 +29,8 @@ import { getActiveWorldStore } from './world-store.ts';
29
29
  export type PushOutcome = { externalId: string; url?: string; data?: Record<string, unknown> };
30
30
  /** What a perform is handed beside the executor: a resolver from a local id to the vendor's (an
31
31
  * entry authored against `twin-1` crosses against `REAL-42` once that subject's landing adopted it). */
32
- /** `credential`: the sealed credential's keyed fingerprint (credential.ts — an HMAC under the user's key,
32
+ /** `credential`: the sealed credential's keyed fingerprint (credential.ts — an HMAC under the sealing key, or the
33
+ * vault's transit hmac at a pinned key version,
33
34
  * never the secret and no offline oracle for it), so a pack keys its budget ledger per real credential:
34
35
  * every World and branch performing with one vendor key then shares that key's one allowance. */
35
36
  export type PerformContext = { resolve: (type: string, localId: string) => string; service?: string; root?: string; credential?: string };
@@ -68,14 +69,45 @@ let sealingKeySource: (() => string) | null = null;
68
69
  export function setSealingKeySource(source: (() => string) | null): void { sealingKeySource = source; }
69
70
  export function sealingKey(): string { return sealingKeySource ? sealingKeySource() : userKek(); }
70
71
 
72
+ /** What seals and opens this process's credentials: the host's sealer (a vault's transit engine, company decision
73
+ * 0034), else a key sealer over `sealingKey()`. */
74
+ let hostSealer: Sealer | null = null;
75
+ export function setSealer(sealer: Sealer | null): void { hostSealer = sealer; }
76
+ export function currentSealer(): Sealer { return hostSealer ?? keySealer(sealingKey); }
77
+
78
+ /**
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
+ */
83
+ export async function openSealedAt(path: string, slot: string): Promise<CredentialPayload | null> {
84
+ const store = getActiveWorldStore();
85
+ const text = store.read(path);
86
+ if (text === null) return null;
87
+ const sealed = JSON.parse(text) as SealedCredential;
88
+ const payload = await currentSealer().open(sealed, slot);
89
+ if (hostSealer && sealed.v === 1) {
90
+ 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 });
95
+ } catch (error) {
96
+ // the credential opened: a move that fails (the vault down, a token expired) waits for the next open
97
+ console.warn(`[credential] ${slot}: not moved to the vault yet (${(error as Error).message})`);
98
+ }
99
+ }
100
+ return payload;
101
+ }
102
+
71
103
  /** A root as a world materializes it: the config plus the path of the sealed credential. */
72
104
  export type BoundRoot = RootConfig & { credential: string };
73
105
 
74
- /** Open the credential a root names, under the user's key. */
106
+ /** Open the credential a root names, under the current sealer. */
75
107
  export async function openRootCredential(root: BoundRoot, vendor: string = vendorOf(root)): Promise<CredentialPayload> {
76
- const sealed = getActiveWorldStore().read(root.credential);
77
- if (sealed === null) throw new Error(`no credential sealed for ${vendor} — \`printf '<token>' | volter twin ${vendor} credential\``);
78
- return openSealedCredential(sealingKey(), JSON.parse(sealed) as SealedCredential, vendor);
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;
79
111
  }
80
112
  /** Seal a credential the vendor rotated (an `exchange` with `rotate`) where the root's was, under the same key and
81
113
  * slot. It is the same credential: its placedAt and fingerprint stay (a pack's budget keys its ledger by the
@@ -85,7 +117,7 @@ export async function resealRootCredential(root: BoundRoot, payload: CredentialP
85
117
  const held = store.read(root.credential);
86
118
  const before = held === null ? null : (JSON.parse(held) as SealedCredential);
87
119
  const now = new Date().toISOString();
88
- const sealed = await sealCredential(sealingKey(), payload, before?.placedAt ?? now, vendor);
120
+ const sealed = await currentSealer().seal(payload, before?.placedAt ?? now, vendor);
89
121
  store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
90
122
  }
91
123
  /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one. */
package/src/index.ts CHANGED
@@ -292,7 +292,7 @@ export { PLACEHOLDER_REMOTE, beginPlaceholderPull, endPlaceholderPull, placehold
292
292
 
293
293
  export { ownFields, subjectHistory, rebaseBranch, cutCheckpoint, appendParentEntry, wholeLog, branchEntries, branchLogPath, branchMetaPath, CHECKPOINT_EVERY, dropCheckpoint, foldEntries, landedCopy, landedIds, parentEntries, parentLogPath, position, readBranchMeta, readTree, toEntry, toEvent, unpushedEntries, writeBranchMeta } from './log.ts';
294
294
  export type { BranchMeta, Entry, EntryOp, Receipt } from './log.ts';
295
- export { sealCredential, openSealedCredential, MemoryCredentialStorage } from './credential.ts';
295
+ export { sealCredential, openSealedCredential, MemoryCredentialStorage, keySealer, transitSealer, type Sealer, type TransitSealerOptions, type SealedByVault, type SealedUnderKey } from './credential.ts';
296
296
  export type { CredentialPayload, CredentialStorage, SealedCredential } from './credential.ts';
297
297
  export { validateRemoteOrigin, buildRemoteExecute, type CredentialCustody } from './executor.ts';
298
298
  export type { TwinAuthStrategy } from './executor.ts';
@@ -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, userKek, userKekPath, vendorOf, worldRootOf, RefusedWriteError, VendorWriteError, HeadError } from './head.ts';
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';
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';