@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.
- package/dist/src/credential.d.ts +18 -7
- package/dist/src/credential.js +40 -13
- package/dist/src/head.d.ts +24 -10
- package/dist/src/head.js +64 -25
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/package.json +1 -1
- package/src/credential.ts +54 -18
- package/src/head.ts +64 -24
- package/src/index.ts +1 -1
package/dist/src/credential.d.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
|
77
|
-
* min_encryption_version raised
|
|
78
|
-
*
|
|
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
|
package/dist/src/credential.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
package/dist/src/head.d.ts
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
48
|
-
* fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when.
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
store.write(path,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
*
|
|
104
|
-
* fingerprint, and a new one per grant would start the ledger over), and rotatedAt says when.
|
|
105
|
-
|
|
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
|
|
145
|
+
const store = getActiveWorldStore();
|
|
146
|
+
const held = store.read(root.credential);
|
|
118
147
|
const fingerprint = held === null ? '' : String(JSON.parse(held).fingerprint ?? '');
|
|
119
|
-
|
|
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'); }
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
190
|
-
* min_encryption_version raised
|
|
191
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
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
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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';
|