run402 4.69.8 → 4.70.0
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/README.md +3 -1
- package/gitvault-surface.json +5 -1
- package/lib/command-manifest.mjs +2 -0
- package/lib/gitvault-capabilities.mjs +6 -0
- package/lib/harness-context.mjs +50 -0
- package/lib/messages.mjs +83 -1
- package/lib/repos.mjs +313 -9
- package/lib/rooms-context.mjs +23 -13
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +1 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +223 -0
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +609 -2
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/rooms.d.ts +20 -1
- package/sdk/dist/namespaces/rooms.d.ts.map +1 -1
- package/sdk/dist/namespaces/rooms.js +93 -0
- package/sdk/dist/namespaces/rooms.js.map +1 -1
- package/sdk/dist/namespaces/rooms.types.d.ts +73 -0
- package/sdk/dist/namespaces/rooms.types.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +1 -0
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +5 -1
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-handoff.d.ts +109 -31
- package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-handoff.js +264 -137
- package/sdk/dist/node/gitvault-handoff.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +2 -2
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.d.ts +19 -0
- package/sdk/dist/node/gitvault-restore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.js +59 -2
- package/sdk/dist/node/gitvault-restore.js.map +1 -1
- package/sdk/dist/node/index.d.ts +4 -4
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +9 -5
- package/sdk/dist/node/index.js.map +1 -1
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gitvault — the Handoff Key
|
|
2
|
+
* gitvault — the Handoff Key and the Invite Key, one kind-parameterized
|
|
3
|
+
* module (kygit-handoff design D3/D6/D10; kygit-invite design D3).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* `kgh1_<base64url(handoff_id[16] || master_secret[32])>`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Both keys are bearer bridges, never a K_repo derivation of their own:
|
|
6
|
+
* `kgh1_<base64url(handoff_id[16] || master_secret[32])>` /
|
|
7
|
+
* `kgi1_<base64url(invite_id[16] || master_secret[32])>` (69 chars each).
|
|
8
|
+
* Two HKDF-SHA-256 derivations off `master_secret` (salt = the 16 raw id
|
|
9
|
+
* bytes) produce `auth_secret` (what the gateway hashes and compares) and
|
|
10
|
+
* `wrap_key` (what seals/opens the small envelope carrying the vault's
|
|
11
|
+
* epoch key `k_e` directly — the gateway never sees either). The HKDF info
|
|
12
|
+
* strings and the auth-hash label are DOMAIN-SEPARATED by kind
|
|
13
|
+
* (`kygit/<kind>/auth/v1`, `kygit/<kind>/wrap/v1`,
|
|
14
|
+
* `kygit/<kind>/auth-hash/v1`) so a handoff-derived secret can never verify
|
|
15
|
+
* as an invite secret nor the reverse, even for a row whose id both parsers
|
|
16
|
+
* would recover.
|
|
10
17
|
*
|
|
11
|
-
* The prefix is a REGISTRY (design D9 rule 4): `kgh1_` is
|
|
12
|
-
* `
|
|
13
|
-
* prefix BY NAME, pointing at its own verb,
|
|
14
|
-
* malformed
|
|
18
|
+
* The prefix is a REGISTRY (design D9 rule 4 / kygit-invite D3): `kgh1_` is
|
|
19
|
+
* the first row, `kgi1_` the second. `parseClaimKey` refuses any other
|
|
20
|
+
* recognized-shape-but-wrong-kind prefix BY NAME, pointing at its own verb,
|
|
21
|
+
* rather than misreading it as a malformed key of the kind it expected.
|
|
22
|
+
* `parseHandoffKey`/`parseInviteKey` are thin kind-bound aliases over the
|
|
23
|
+
* one parser — every existing handoff export and test stays byte-identical.
|
|
15
24
|
*
|
|
16
25
|
* Reuses ONLY existing primitives — `jcs`/HKDF/HMAC/SHA-256 from
|
|
17
26
|
* `@noble/hashes`, the SAME `_gitvaultAeadBackend()` XChaCha20-Poly1305
|
|
@@ -20,7 +29,10 @@
|
|
|
20
29
|
* object frame (design D3: "the sealed envelope lives in the gateway ROW,
|
|
21
30
|
* never in the vault object store — the frozen r402s/v0 protocol is
|
|
22
31
|
* untouched"), so it gets its own tiny header/AAD rather than reusing
|
|
23
|
-
* `frameAad`'s object-store-shaped seven fields.
|
|
32
|
+
* `frameAad`'s object-store-shaped seven fields. The invite envelope is a
|
|
33
|
+
* DIFFERENT, deliberately simpler format again than the `KGH1` handoff-
|
|
34
|
+
* bridge frame is from `r402s/v0` — same idea, one level down: `KGI1` is its
|
|
35
|
+
* own frame magic, never reused across kinds.
|
|
24
36
|
*/
|
|
25
37
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
26
38
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
@@ -32,11 +44,20 @@ import { _gitvaultAeadBackend, fromBase64url, randomBytes, toBase64url, bytesToH
|
|
|
32
44
|
function fail(code, message, context, details) {
|
|
33
45
|
throw new LocalError(message, context, { code, details });
|
|
34
46
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `kgh1_` is the first row (handoff); `kgi1_` is the second (invite,
|
|
49
|
+
* kygit-invite design D3). A future third kind is a SIBLING row here, never
|
|
50
|
+
* a branch on either existing one's parser.
|
|
51
|
+
*/
|
|
52
|
+
export const HANDOFF_KEY_PREFIXES = [
|
|
53
|
+
{ prefix: "kgh1_", kind: "handoff", verb: "resume", errorPrefix: "HANDOFF", envelopeKind: "kygit-handoff-envelope-v2", noteSchema: "kygit.handoff-note.v1", frameMagic: "KGH1" },
|
|
54
|
+
{ prefix: "kgi1_", kind: "invite", verb: "join", errorPrefix: "INVITE", envelopeKind: "kygit-invite-envelope-v2", noteSchema: "kygit.invite-note.v1", frameMagic: "KGI1" },
|
|
55
|
+
];
|
|
56
|
+
const HANDOFF_ENTRY = HANDOFF_KEY_PREFIXES[0];
|
|
57
|
+
const INVITE_ENTRY = HANDOFF_KEY_PREFIXES[1];
|
|
58
|
+
const CLAIM_ID_BYTES = 16;
|
|
38
59
|
const MASTER_SECRET_BYTES = 32;
|
|
39
|
-
const
|
|
60
|
+
const CLAIM_KEY_BODY_BYTES = CLAIM_ID_BYTES + MASTER_SECRET_BYTES;
|
|
40
61
|
function bytesToUuid(bytes) {
|
|
41
62
|
const h = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
42
63
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
@@ -51,6 +72,14 @@ export function uuidToBytes(uuid, field = "handoff_id") {
|
|
|
51
72
|
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
52
73
|
return out;
|
|
53
74
|
}
|
|
75
|
+
/** Assemble a printed claim key from a fresh 32-byte master secret and the gateway-minted claim id, for `entry`'s kind. */
|
|
76
|
+
function assembleClaimKey(entry, claimId, masterSecret) {
|
|
77
|
+
if (masterSecret.length !== MASTER_SECRET_BYTES)
|
|
78
|
+
fail(`${entry.errorPrefix}_KEY_INVALID`, "master_secret must be 32 bytes", `assembling ${entry.kind} key`);
|
|
79
|
+
const idBytes = uuidToBytes(claimId, `${entry.kind}_id`);
|
|
80
|
+
const body = concatBytes(idBytes, masterSecret);
|
|
81
|
+
return { key: `${entry.prefix}${toBase64url(body)}`, id_bytes: idBytes, master_secret: masterSecret };
|
|
82
|
+
}
|
|
54
83
|
/**
|
|
55
84
|
* Assemble the printed key from a fresh 32-byte master secret and the
|
|
56
85
|
* gateway-minted `handoff_id`. The gateway never sees `master_secret` — the
|
|
@@ -58,64 +87,86 @@ export function uuidToBytes(uuid, field = "handoff_id") {
|
|
|
58
87
|
* ONLY that.
|
|
59
88
|
*/
|
|
60
89
|
export function assembleHandoffKey(handoffId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
90
|
+
const { key, id_bytes, master_secret } = assembleClaimKey(HANDOFF_ENTRY, handoffId, masterSecret);
|
|
91
|
+
return { key, handoff_id_bytes: id_bytes, master_secret };
|
|
92
|
+
}
|
|
93
|
+
/** The invite-kind sibling of {@link assembleHandoffKey} (kygit-invite design D3). */
|
|
94
|
+
export function assembleInviteKey(inviteId, masterSecret = randomBytes(MASTER_SECRET_BYTES)) {
|
|
95
|
+
const { key, id_bytes, master_secret } = assembleClaimKey(INVITE_ENTRY, inviteId, masterSecret);
|
|
96
|
+
return { key, invite_id_bytes: id_bytes, master_secret };
|
|
66
97
|
}
|
|
67
98
|
/**
|
|
68
|
-
* Parse ANY `kg**_`
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
99
|
+
* Parse ANY `kg**_` claim-family key by its prefix registry against the
|
|
100
|
+
* `expectedKind` the calling verb accepts. A recognized prefix of a
|
|
101
|
+
* DIFFERENT kind (a `kgi1_` invite key handed to `resume`, or a `kgh1_`
|
|
102
|
+
* handoff key handed to `join`) refuses BY NAME pointing at its own verb —
|
|
103
|
+
* never misread as a malformed key of the expected kind, and never
|
|
104
|
+
* contacting the gateway (design D9 rule 4 / kygit-invite design D3).
|
|
72
105
|
*/
|
|
73
|
-
export function
|
|
106
|
+
export function parseClaimKey(raw, expectedKind) {
|
|
107
|
+
const expectedEntry = expectedKind === "handoff" ? HANDOFF_ENTRY : INVITE_ENTRY;
|
|
74
108
|
const trimmed = raw.trim();
|
|
75
109
|
const entry = HANDOFF_KEY_PREFIXES.find((e) => trimmed.startsWith(e.prefix));
|
|
76
110
|
if (!entry) {
|
|
77
|
-
|
|
78
|
-
// it joins the registry; today only `kgh1_` is registered, so any
|
|
79
|
-
// non-matching prefix is a plain invalid key.
|
|
80
|
-
fail("HANDOFF_KEY_INVALID", "not a recognized kygit key (expected a key starting kgh1_)", "parsing handoff key");
|
|
111
|
+
fail(`${expectedEntry.errorPrefix}_KEY_INVALID`, `not a recognized kygit key (expected a key starting ${expectedEntry.prefix})`, "parsing claim key");
|
|
81
112
|
}
|
|
82
|
-
if (entry.kind !==
|
|
83
|
-
fail(
|
|
113
|
+
if (entry.kind !== expectedKind) {
|
|
114
|
+
fail(`${expectedEntry.errorPrefix}_KEY_WRONG_KIND`, `this key is a ${entry.kind} key — use \`kygit ${entry.verb}\` instead of ${expectedEntry.verb}`, "parsing claim key", { kind: entry.kind, verb: entry.verb });
|
|
84
115
|
}
|
|
85
116
|
const body = trimmed.slice(entry.prefix.length);
|
|
86
117
|
let bytes;
|
|
87
118
|
try {
|
|
88
|
-
bytes = fromBase64url(body,
|
|
119
|
+
bytes = fromBase64url(body, `${entry.kind} key body`);
|
|
89
120
|
}
|
|
90
121
|
catch {
|
|
91
|
-
fail(
|
|
122
|
+
fail(`${entry.errorPrefix}_KEY_INVALID`, "the key body is not valid base64url", "parsing claim key");
|
|
92
123
|
}
|
|
93
|
-
if (bytes.length !==
|
|
94
|
-
fail(
|
|
124
|
+
if (bytes.length !== CLAIM_KEY_BODY_BYTES) {
|
|
125
|
+
fail(`${entry.errorPrefix}_KEY_INVALID`, `the key decodes to ${bytes.length} bytes; expected ${CLAIM_KEY_BODY_BYTES}`, "parsing claim key");
|
|
95
126
|
}
|
|
96
|
-
const idBytes = bytes.subarray(0,
|
|
97
|
-
const masterSecret = bytes.subarray(
|
|
98
|
-
return { kind:
|
|
127
|
+
const idBytes = bytes.subarray(0, CLAIM_ID_BYTES);
|
|
128
|
+
const masterSecret = bytes.subarray(CLAIM_ID_BYTES);
|
|
129
|
+
return { kind: entry.kind, id_bytes: idBytes, id: bytesToUuid(idBytes), master_secret: masterSecret };
|
|
99
130
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
131
|
+
/**
|
|
132
|
+
* Parse ANY `kg**_` handoff-family key by its prefix registry. A recognized
|
|
133
|
+
* prefix of a DIFFERENT kind (an invite key handed to `resume`) refuses BY
|
|
134
|
+
* NAME pointing at its own verb — never misread as a malformed handoff key
|
|
135
|
+
* (design D9 rule 4). Kind-bound alias of {@link parseClaimKey}.
|
|
136
|
+
*/
|
|
137
|
+
export function parseHandoffKey(raw) {
|
|
138
|
+
const parsed = parseClaimKey(raw, "handoff");
|
|
139
|
+
return { kind: "handoff", handoff_id_bytes: parsed.id_bytes, handoff_id: parsed.id, master_secret: parsed.master_secret };
|
|
140
|
+
}
|
|
141
|
+
/** The invite-kind sibling of {@link parseHandoffKey} (kygit-invite design D3). */
|
|
142
|
+
export function parseInviteKey(raw) {
|
|
143
|
+
const parsed = parseClaimKey(raw, "invite");
|
|
144
|
+
return { kind: "invite", invite_id_bytes: parsed.id_bytes, invite_id: parsed.id, master_secret: parsed.master_secret };
|
|
145
|
+
}
|
|
146
|
+
/** `HKDF-SHA256(ikm=master_secret, salt=id[16], info=…)` per D3, domain-separated by kind, for both `auth_secret` and `wrap_key`. */
|
|
147
|
+
function deriveClaimSecrets(kind, idBytes, masterSecret) {
|
|
148
|
+
const authInfo = `kygit/${kind}/auth/v1`;
|
|
149
|
+
const wrapInfo = `kygit/${kind}/wrap/v1`;
|
|
150
|
+
const authHashLabel = `kygit/${kind}/auth-hash/v1`;
|
|
151
|
+
const authSecret = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(authInfo), 32);
|
|
152
|
+
const wrapKey = hkdf(sha256, masterSecret, idBytes, utf8ToBytes(wrapInfo), 32);
|
|
153
|
+
const authHash = sha256(concatBytes(utf8ToBytes(authHashLabel), authSecret));
|
|
108
154
|
// ONE SHA-256 over (label ‖ auth_secret), hex-encoded — exactly what the
|
|
109
155
|
// gateway recomputes at claim (`computeAuthHash`). Through 4.68.1 this line
|
|
110
156
|
// ran the digest through `sha256Hex` (which hashes its input again), so the
|
|
111
157
|
// stored value was sha256(sha256(label ‖ secret)) and NO key minted by any
|
|
112
|
-
// published client could ever verify
|
|
113
|
-
//
|
|
114
|
-
// cross-side vector in gitvault-handoff.test.ts now recomputes the
|
|
115
|
-
// gateway's hash independently.
|
|
116
|
-
const authHash = sha256(concatBytes(utf8ToBytes(HANDOFF_AUTH_HASH_LABEL), authSecret));
|
|
158
|
+
// published client could ever verify. The cross-side vector in
|
|
159
|
+
// gitvault-handoff.test.ts now recomputes the gateway's hash independently.
|
|
117
160
|
return { auth_secret: authSecret, wrap_key: wrapKey, auth_hash_hex: bytesToHex(authHash) };
|
|
118
161
|
}
|
|
162
|
+
/** `HKDF-SHA256(ikm=master_secret, salt=handoff_id[16], info=kygit/handoff/…)` per D3, for both `auth_secret` and `wrap_key`. */
|
|
163
|
+
export function deriveHandoffSecrets(handoffIdBytes, masterSecret) {
|
|
164
|
+
return deriveClaimSecrets("handoff", handoffIdBytes, masterSecret);
|
|
165
|
+
}
|
|
166
|
+
/** The invite-kind sibling of {@link deriveHandoffSecrets}, domain-separated under `kygit/invite/…` info strings (kygit-invite design D3) — an invite secret never verifies as a handoff hash, or the reverse. */
|
|
167
|
+
export function deriveInviteSecrets(inviteIdBytes, masterSecret) {
|
|
168
|
+
return deriveClaimSecrets("invite", inviteIdBytes, masterSecret);
|
|
169
|
+
}
|
|
119
170
|
// ─── Writer-admission grant + acceptance (gitvault-multi-writer D4/§4.17) ───
|
|
120
171
|
//
|
|
121
172
|
// The bearer-completable path for `add_writer_key{authorization.kind:
|
|
@@ -131,17 +182,27 @@ export function deriveHandoffSecrets(handoffIdBytes, masterSecret) {
|
|
|
131
182
|
// JCS(statement)` — that text is stale; the shipped gateway code calls its
|
|
132
183
|
// own `signaturePreimage("handoff-writer-accept/v1", acceptance.statement)`,
|
|
133
184
|
// the identical convention used here).
|
|
134
|
-
const HANDOFF_WRITER_ADMISSION_INFO = "kygit/handoff/writer-admission/v1";
|
|
135
185
|
/**
|
|
136
|
-
* Third HKDF output of
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* `wrap_key` — so the gateway,
|
|
140
|
-
*
|
|
141
|
-
* claimant completion.
|
|
186
|
+
* Third HKDF output of a claim key's `master_secret` (design D4, protocol
|
|
187
|
+
* §4.17; kind-parameterized by kygit-invite design D3): the one-use
|
|
188
|
+
* admission Ed25519 seed. Derived directly from `master_secret` —
|
|
189
|
+
* deliberately NEVER from `auth_secret` or `wrap_key` — so the gateway,
|
|
190
|
+
* which receives `auth_secret` at claim, stays computationally unable to
|
|
191
|
+
* derive this seed and manufacture a different claimant completion. The info
|
|
192
|
+
* string is domain-separated by kind (`kygit/<kind>/writer-admission/v1`),
|
|
193
|
+
* so an invite's admission seed never matches a handoff's for the same id
|
|
194
|
+
* and master secret.
|
|
142
195
|
*/
|
|
196
|
+
export function deriveClaimWriterAdmissionSeed(kind, idBytes, masterSecret) {
|
|
197
|
+
return hkdf(sha256, masterSecret, idBytes, utf8ToBytes(`kygit/${kind}/writer-admission/v1`), 32);
|
|
198
|
+
}
|
|
199
|
+
/** Handoff-bound alias of {@link deriveClaimWriterAdmissionSeed} — `kygit/handoff/writer-admission/v1`. */
|
|
143
200
|
export function deriveWriterAdmissionSeed(handoffIdBytes, masterSecret) {
|
|
144
|
-
return
|
|
201
|
+
return deriveClaimWriterAdmissionSeed("handoff", handoffIdBytes, masterSecret);
|
|
202
|
+
}
|
|
203
|
+
/** The invite-kind sibling of {@link deriveWriterAdmissionSeed} — `kygit/invite/writer-admission/v1` (kygit-invite design D3). */
|
|
204
|
+
export function deriveInviteWriterAdmissionSeed(inviteIdBytes, masterSecret) {
|
|
205
|
+
return deriveClaimWriterAdmissionSeed("invite", inviteIdBytes, masterSecret);
|
|
145
206
|
}
|
|
146
207
|
const WRITER_MINTED_ROLES = ["owner", "admin", "developer", "billing", "viewer"];
|
|
147
208
|
/** Mint side (D4): builds + signs `writer_admission_grant`. The gateway's own mint-time validation (grantor active + self-consistent, field equality) is server-side; this function only builds a well-formed, correctly-signed object. */
|
|
@@ -251,20 +312,19 @@ export function verifyWriterAcceptance(acceptance, admissionPubkeyRaw) {
|
|
|
251
312
|
const preimage = signaturePreimage(HANDOFF_WRITER_ACCEPT_PREIMAGE_DOMAIN, acceptance.statement);
|
|
252
313
|
return ed25519VerifyStrict(acceptanceSig, preimage, admissionPubkeyRaw) && ed25519VerifyStrict(possessionSig, preimage, signingPubkey);
|
|
253
314
|
}
|
|
254
|
-
/** Frame tag for
|
|
315
|
+
/** Frame tag for the handoff envelope format — DISTINCT from `r402s/v0`'s `"R402S0"` object frame (design D3: never stored as a vault object). The LEGACY v1 tag: nothing seals it any more, but a pre-gitvault-multi-writer handoff row still carries one and {@link openHandoffEnvelope} still opens it. */
|
|
255
316
|
export const HANDOFF_ENVELOPE_KIND = "kygit-handoff-envelope-v1";
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
function handoffAeadSeal(key32, nonce24, aad, plaintext) {
|
|
317
|
+
const FRAME_MAGIC_BYTES = 4;
|
|
318
|
+
const FRAME_VERSION_BYTE = 0x01;
|
|
319
|
+
const CLAIM_NONCE_BYTES = 24;
|
|
320
|
+
const CLAIM_FRAME_HEADER_BYTES = FRAME_MAGIC_BYTES + 1 + CLAIM_NONCE_BYTES;
|
|
321
|
+
function claimAeadSeal(key32, nonce24, aad, plaintext) {
|
|
262
322
|
const backend = _gitvaultAeadBackend();
|
|
263
323
|
if (backend)
|
|
264
324
|
return backend.seal(key32, nonce24, aad, plaintext);
|
|
265
325
|
return xchacha20poly1305(key32, nonce24, aad).encrypt(plaintext);
|
|
266
326
|
}
|
|
267
|
-
function
|
|
327
|
+
function claimAeadOpen(key32, nonce24, aad, ctAndTag) {
|
|
268
328
|
try {
|
|
269
329
|
const backend = _gitvaultAeadBackend();
|
|
270
330
|
if (backend)
|
|
@@ -275,9 +335,9 @@ function handoffAeadOpen(key32, nonce24, aad, ctAndTag) {
|
|
|
275
335
|
return null;
|
|
276
336
|
}
|
|
277
337
|
}
|
|
278
|
-
/** AAD = `
|
|
279
|
-
function
|
|
280
|
-
return concatBytes(
|
|
338
|
+
/** AAD = `id[16] ‖ envelopeKind` (design D3) — `envelopeKind` is this envelope format's own tag, UTF-8. */
|
|
339
|
+
function claimEnvelopeAad(idBytes, envelopeKind) {
|
|
340
|
+
return concatBytes(idBytes, utf8ToBytes(envelopeKind));
|
|
281
341
|
}
|
|
282
342
|
/**
|
|
283
343
|
* `sealed_envelope` on the wire is STANDARD base64 — openapi spells it
|
|
@@ -285,11 +345,14 @@ function handoffEnvelopeAad(handoffIdBytes, envelopeKind) {
|
|
|
285
345
|
* gateway echoes the stored bytes with `Buffer#toString("base64")` (`+`,
|
|
286
346
|
* `/`, `=` padding). Decoding it with the canonical-base64url-only
|
|
287
347
|
* `fromBase64url`, which refuses every `+`/`/`/`=`, takes a 200 from the
|
|
288
|
-
* gateway and dies client-side with
|
|
348
|
+
* gateway and dies client-side with `*_ENVELOPE_INVALID`. This decoder
|
|
289
349
|
* reads the documented form and, for tolerance, the base64url form other
|
|
290
|
-
* clients mint; padding is optional either way. Anything
|
|
291
|
-
*
|
|
292
|
-
*
|
|
350
|
+
* clients mint; padding is optional either way. Anything outside the two
|
|
351
|
+
* alphabets is a refusal (`null` — the caller raises the typed
|
|
352
|
+
* `*_ENVELOPE_INVALID`), never a silent partial decode. Shared by both
|
|
353
|
+
* claim kinds: the invite routes encode exactly as the handoff routes do,
|
|
354
|
+
* byte for byte, rather than "fixing" the alphabet on one side only
|
|
355
|
+
* (kygit-invite design D3's cross-side trap).
|
|
293
356
|
*/
|
|
294
357
|
function decodeSealedEnvelope(value) {
|
|
295
358
|
const trimmed = value.trim();
|
|
@@ -305,91 +368,107 @@ function decodeSealedEnvelope(value) {
|
|
|
305
368
|
return null;
|
|
306
369
|
}
|
|
307
370
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Seal `payload` under `wrap_key` for `entry`'s kind, tagged `envelopeKind`.
|
|
373
|
+
* Returns the standard-base64 wire form (openapi `format: byte`) + the tag.
|
|
374
|
+
*
|
|
375
|
+
* Plain JSON, NOT the r402s/v0 no-JSON-numbers JCS profile: this envelope is
|
|
376
|
+
* never a vault object and only this SDK ever encodes/decodes it, so it
|
|
377
|
+
* needs deterministic round-tripping, not cross-implementation
|
|
378
|
+
* byte-canonical determinism (design D3's "the frozen protocol is
|
|
379
|
+
* untouched" — reusing the object-frame profile here would be exactly the
|
|
380
|
+
* kind of accidental protocol coupling that note warns against). Shared by
|
|
381
|
+
* every claim kind and every envelope version (v1, v2, …) so the frame
|
|
382
|
+
* byte-layout lives in exactly one place — a kind or version divergence here
|
|
383
|
+
* would be invisible to either side until a cross-open failed.
|
|
384
|
+
*/
|
|
385
|
+
function sealClaimEnvelope(entry, idBytes, wrapKey, payload, envelopeKind, nonce) {
|
|
318
386
|
const plaintext = utf8ToBytes(JSON.stringify(payload));
|
|
319
|
-
const n = nonce ?? randomBytes(
|
|
320
|
-
if (n.length !==
|
|
321
|
-
fail(
|
|
322
|
-
const aad =
|
|
323
|
-
const ct =
|
|
324
|
-
const frame = concatBytes(utf8ToBytes(
|
|
387
|
+
const n = nonce ?? randomBytes(CLAIM_NONCE_BYTES);
|
|
388
|
+
if (n.length !== CLAIM_NONCE_BYTES)
|
|
389
|
+
fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "nonce must be 24 bytes", `sealing ${entry.kind} envelope`);
|
|
390
|
+
const aad = claimEnvelopeAad(idBytes, envelopeKind);
|
|
391
|
+
const ct = claimAeadSeal(wrapKey, n, aad, plaintext);
|
|
392
|
+
const frame = concatBytes(utf8ToBytes(entry.frameMagic), new Uint8Array([FRAME_VERSION_BYTE]), n, ct);
|
|
325
393
|
return { sealed_envelope: base64.encode(frame), envelope_kind: envelopeKind };
|
|
326
394
|
}
|
|
327
|
-
/** Seal the handoff payload under `wrap_key`. Returns the standard-base64 wire form (openapi `format: byte`) + its declared kind tag. */
|
|
328
|
-
export function sealHandoffEnvelope(handoffIdBytes, wrapKey, payload, nonce) {
|
|
329
|
-
return sealHandoffEnvelopeFrame(handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_KIND, nonce);
|
|
330
|
-
}
|
|
331
395
|
/**
|
|
332
|
-
* Decodes, header-checks, AEAD-opens, and JSON-parses a sealed envelope
|
|
333
|
-
* returning the RAW parsed payload with no shape validation
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
396
|
+
* Decodes, header-checks, AEAD-opens, and JSON-parses a sealed envelope for
|
|
397
|
+
* `entry`'s kind, returning the RAW parsed payload with no shape validation
|
|
398
|
+
* (mirrors {@link sealClaimEnvelope} on the seal side) so the frame
|
|
399
|
+
* byte-layout lives in exactly one place. Each envelope kind/version's own
|
|
400
|
+
* `open*` function applies its OWN shape check on the result; this function
|
|
401
|
+
* deliberately does not, since a shape check IS the version discriminator (a
|
|
402
|
+
* v1 opener must reject a `v:2` payload, not silently accept it — that is
|
|
403
|
+
* what makes `v` meaningful at all). Throws `${errorPrefix}_ENVELOPE_INVALID`
|
|
404
|
+
* on any header mismatch and `${errorPrefix}_AEAD_AUTH_FAILURE` on a bad
|
|
405
|
+
* key/AAD/ciphertext.
|
|
340
406
|
*/
|
|
341
|
-
function
|
|
407
|
+
function openClaimEnvelope(entry, idBytes, wrapKey, sealedEnvelope, envelopeKind) {
|
|
342
408
|
const frame = decodeSealedEnvelope(sealedEnvelope);
|
|
343
409
|
if (frame === null) {
|
|
344
|
-
fail(
|
|
410
|
+
fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is not valid base64", `opening ${entry.kind} envelope`);
|
|
345
411
|
}
|
|
346
|
-
if (frame.length <
|
|
347
|
-
fail(
|
|
412
|
+
if (frame.length < CLAIM_FRAME_HEADER_BYTES + 16) {
|
|
413
|
+
fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope is shorter than header + AEAD tag", `opening ${entry.kind} envelope`);
|
|
348
414
|
}
|
|
349
|
-
const magic = new TextDecoder().decode(frame.subarray(0,
|
|
350
|
-
if (magic !==
|
|
351
|
-
fail(
|
|
415
|
+
const magic = new TextDecoder().decode(frame.subarray(0, FRAME_MAGIC_BYTES));
|
|
416
|
+
if (magic !== entry.frameMagic || frame[FRAME_MAGIC_BYTES] !== FRAME_VERSION_BYTE) {
|
|
417
|
+
fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "sealed_envelope header magic/version mismatch", `opening ${entry.kind} envelope`);
|
|
352
418
|
}
|
|
353
|
-
const nonce = frame.subarray(
|
|
354
|
-
const ct = frame.subarray(
|
|
355
|
-
const aad =
|
|
356
|
-
const opened =
|
|
419
|
+
const nonce = frame.subarray(FRAME_MAGIC_BYTES + 1, CLAIM_FRAME_HEADER_BYTES);
|
|
420
|
+
const ct = frame.subarray(CLAIM_FRAME_HEADER_BYTES);
|
|
421
|
+
const aad = claimEnvelopeAad(idBytes, envelopeKind);
|
|
422
|
+
const opened = claimAeadOpen(wrapKey, nonce, aad, ct);
|
|
357
423
|
if (opened === null)
|
|
358
|
-
fail(
|
|
424
|
+
fail(`${entry.errorPrefix}_AEAD_AUTH_FAILURE`, "the sealed envelope failed AEAD authentication under this key", `opening ${entry.kind} envelope`);
|
|
359
425
|
try {
|
|
360
426
|
return JSON.parse(new TextDecoder().decode(opened));
|
|
361
427
|
}
|
|
362
428
|
catch {
|
|
363
|
-
fail(
|
|
429
|
+
fail(`${entry.errorPrefix}_ENVELOPE_INVALID`, "opened envelope is not valid JSON", `opening ${entry.kind} envelope`);
|
|
364
430
|
}
|
|
365
431
|
}
|
|
432
|
+
/** Seal the handoff payload under `wrap_key` as a LEGACY v1 envelope. Returns the standard-base64 wire form (openapi `format: byte`) + its declared kind tag. Nothing in the shipped flow calls this any more — {@link sealHandoffEnvelopeV2} is what `handoff` seals — but the v1 shape stays openable and sealable for the conformance vectors. */
|
|
433
|
+
export function sealHandoffEnvelope(handoffIdBytes, wrapKey, payload, nonce) {
|
|
434
|
+
return sealClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_KIND, nonce);
|
|
435
|
+
}
|
|
366
436
|
/** Open a sealed handoff envelope under `wrap_key` (standard base64 as the claim response carries it, or base64url). Throws `HANDOFF_ENVELOPE_INVALID` on any header mismatch, a `v:2` (or otherwise non-v1) payload shape, and `HANDOFF_AEAD_AUTH_FAILURE` on a bad key/AAD/ciphertext. */
|
|
367
437
|
export function openHandoffEnvelope(handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind = HANDOFF_ENVELOPE_KIND) {
|
|
368
|
-
const
|
|
369
|
-
const p = payload;
|
|
438
|
+
const p = openClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
|
|
370
439
|
if (p.v !== 1 || p.kind !== "handoff" || typeof p.repo_id !== "string" || typeof p.epoch !== "string" || typeof p.k_e_hex !== "string" || !p.checkpoint || p.note_schema !== "kygit.handoff-note.v1") {
|
|
371
440
|
fail("HANDOFF_ENVELOPE_INVALID", "opened envelope does not match the kygit.handoff-note.v1 payload shape", "opening handoff envelope");
|
|
372
441
|
}
|
|
373
442
|
return p;
|
|
374
443
|
}
|
|
375
|
-
// ─── Envelope v2 (gitvault-multi-writer D4/§4.17)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
444
|
+
// ─── Envelope v2 (gitvault-multi-writer D4/§4.17; kygit-invite design D3) ───
|
|
445
|
+
//
|
|
446
|
+
// `writer_admission_grant_sha256` is what makes v2 v2 — design D4's "no hash
|
|
447
|
+
// cycle: grant first, then seal": the minter builds + signs
|
|
448
|
+
// {@link WriterAdmissionGrant} FIRST, hashes its stored bytes SECOND, and
|
|
449
|
+
// only THEN seals this envelope carrying that hash. The claimant
|
|
450
|
+
// cross-checks it against the grant the gateway independently returns at
|
|
451
|
+
// claim, an integrity binding entirely independent of anything the gateway
|
|
452
|
+
// could tamper with (the envelope's AEAD authenticity comes from `wrap_key`,
|
|
453
|
+
// which the gateway never holds). BOTH claim kinds seal v2 — an invite has
|
|
454
|
+
// no v1 at all, and a handoff's v1 survives only as a legacy opener.
|
|
455
|
+
/** The v2 envelope kind the handoff mint seals. */
|
|
386
456
|
export const HANDOFF_ENVELOPE_V2_KIND = "kygit-handoff-envelope-v2";
|
|
387
|
-
/**
|
|
457
|
+
/** The v2 envelope kind the invite mint seals (kygit-invite design D3) — its own tag, never confusable with the handoff one even for the same id. */
|
|
458
|
+
export const INVITE_ENVELOPE_V2_KIND = "kygit-invite-envelope-v2";
|
|
459
|
+
/** Seal a v2 handoff envelope — identical framing to {@link sealHandoffEnvelope}, tagged `kygit-handoff-envelope-v2` so its AAD (and therefore its ciphertext) is never confusable with a v1 envelope of the same handoff. */
|
|
388
460
|
export function sealHandoffEnvelopeV2(handoffIdBytes, wrapKey, payload, nonce) {
|
|
389
461
|
if (!GITVAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) {
|
|
390
462
|
fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing handoff envelope v2", { field: "writer_admission_grant_sha256" });
|
|
391
463
|
}
|
|
392
|
-
return
|
|
464
|
+
return sealClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, payload, HANDOFF_ENVELOPE_V2_KIND, nonce);
|
|
465
|
+
}
|
|
466
|
+
/** The invite-kind sibling of {@link sealHandoffEnvelopeV2} — the ONLY invite envelope there is (kygit-invite design D3). */
|
|
467
|
+
export function sealInviteEnvelope(inviteIdBytes, wrapKey, payload, nonce) {
|
|
468
|
+
if (!GITVAULT_SHA256_RE.test(payload.writer_admission_grant_sha256)) {
|
|
469
|
+
fail("VALIDATION_FAILED", "writer_admission_grant_sha256 must be 64 lowercase hex", "sealing invite envelope", { field: "writer_admission_grant_sha256" });
|
|
470
|
+
}
|
|
471
|
+
return sealClaimEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, payload, INVITE_ENVELOPE_V2_KIND, nonce);
|
|
393
472
|
}
|
|
394
473
|
/**
|
|
395
474
|
* Open a v2 envelope for a writer-activation flow. Refuses a v1 (or any
|
|
@@ -398,7 +477,7 @@ export function sealHandoffEnvelopeV2(handoffIdBytes, wrapKey, payload, nonce) {
|
|
|
398
477
|
* writer-activation-aware `resume` needs `writer_admission_grant_sha256` to
|
|
399
478
|
* cross-check the returned grant, and a pre-rev-47 v1 envelope structurally
|
|
400
479
|
* has none. Shares ONLY the frame/AEAD-opening step with
|
|
401
|
-
* {@link openHandoffEnvelope} ({@link
|
|
480
|
+
* {@link openHandoffEnvelope} ({@link openClaimEnvelope}) — NOT that
|
|
402
481
|
* function itself, since its `v !== 1` shape check would (correctly) refuse
|
|
403
482
|
* a v2 payload; this function applies its own `v !== 2` shape check
|
|
404
483
|
* instead. Callers that only need K_repo/checkpoint delivery, with no
|
|
@@ -410,8 +489,7 @@ export function openHandoffEnvelopeV2(handoffIdBytes, wrapKey, sealedEnvelope, e
|
|
|
410
489
|
if (envelopeKind !== HANDOFF_ENVELOPE_V2_KIND) {
|
|
411
490
|
fail("HANDOFF_ENVELOPE_UNSUPPORTED", `this handoff's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${HANDOFF_ENVELOPE_V2_KIND}\` — writer activation needs the v2 envelope's writer_admission_grant_sha256, which a pre-gitvault-multi-writer handoff never carries`, "opening handoff envelope for writer activation", { received: envelopeKind, required: HANDOFF_ENVELOPE_V2_KIND });
|
|
412
491
|
}
|
|
413
|
-
const
|
|
414
|
-
const p = payload;
|
|
492
|
+
const p = openClaimEnvelope(HANDOFF_ENTRY, handoffIdBytes, wrapKey, sealedEnvelope, envelopeKind);
|
|
415
493
|
if (p.v !== 2 ||
|
|
416
494
|
p.kind !== "handoff" ||
|
|
417
495
|
typeof p.repo_id !== "string" ||
|
|
@@ -425,12 +503,37 @@ export function openHandoffEnvelopeV2(handoffIdBytes, wrapKey, sealedEnvelope, e
|
|
|
425
503
|
}
|
|
426
504
|
return p;
|
|
427
505
|
}
|
|
506
|
+
/** The invite-kind sibling of {@link openHandoffEnvelopeV2} — throws `INVITE_ENVELOPE_UNSUPPORTED` / `INVITE_ENVELOPE_INVALID` / `INVITE_AEAD_AUTH_FAILURE` (kygit-invite design D3). */
|
|
507
|
+
export function openInviteEnvelope(inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind = INVITE_ENVELOPE_V2_KIND) {
|
|
508
|
+
if (envelopeKind !== INVITE_ENVELOPE_V2_KIND) {
|
|
509
|
+
fail("INVITE_ENVELOPE_UNSUPPORTED", `this invite's envelope is ${envelopeKind ? `\`${envelopeKind}\`` : "of an unrecognized kind"}, not \`${INVITE_ENVELOPE_V2_KIND}\``, "opening invite envelope", { received: envelopeKind, required: INVITE_ENVELOPE_V2_KIND });
|
|
510
|
+
}
|
|
511
|
+
const p = openClaimEnvelope(INVITE_ENTRY, inviteIdBytes, wrapKey, sealedEnvelope, envelopeKind);
|
|
512
|
+
if (p.v !== 2 ||
|
|
513
|
+
p.kind !== "invite" ||
|
|
514
|
+
typeof p.repo_id !== "string" ||
|
|
515
|
+
typeof p.epoch !== "string" ||
|
|
516
|
+
typeof p.k_e_hex !== "string" ||
|
|
517
|
+
!p.checkpoint ||
|
|
518
|
+
p.note_schema !== "kygit.invite-note.v1" ||
|
|
519
|
+
typeof p.writer_admission_grant_sha256 !== "string" ||
|
|
520
|
+
!GITVAULT_SHA256_RE.test(p.writer_admission_grant_sha256)) {
|
|
521
|
+
fail("INVITE_ENVELOPE_INVALID", "opened envelope does not match the v2 kygit.invite-note.v1 payload shape", "opening invite envelope");
|
|
522
|
+
}
|
|
523
|
+
return p;
|
|
524
|
+
}
|
|
428
525
|
export function isKygitHandoffNote(value) {
|
|
429
526
|
if (!value || typeof value !== "object")
|
|
430
527
|
return false;
|
|
431
528
|
const v = value;
|
|
432
529
|
return v.schema === "kygit.handoff-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture;
|
|
433
530
|
}
|
|
531
|
+
export function isKygitInviteNote(value) {
|
|
532
|
+
if (!value || typeof value !== "object")
|
|
533
|
+
return false;
|
|
534
|
+
const v = value;
|
|
535
|
+
return v.schema === "kygit.invite-note.v1" && typeof v.summary === "string" && typeof v.created_at === "string" && !!v.from && !!v.capture;
|
|
536
|
+
}
|
|
434
537
|
// ─── Client-side secret scan (design D10 — no override flag) ────────────────
|
|
435
538
|
const HANDOFF_NOTE_SECRET_PREFIXES = [
|
|
436
539
|
/\bsk-[A-Za-z0-9]/,
|
|
@@ -441,6 +544,10 @@ const HANDOFF_NOTE_SECRET_PREFIXES = [
|
|
|
441
544
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
442
545
|
/-----BEGIN [A-Z ]*CERTIFICATE-----/,
|
|
443
546
|
/\bkgh1_[A-Za-z0-9_-]/,
|
|
547
|
+
// kygit-invite design D3: the invite key joins the scan's known-prefix
|
|
548
|
+
// list so a pasted kgi1_ reads as a bare secret in EITHER note kind, not
|
|
549
|
+
// just its own.
|
|
550
|
+
/\bkgi1_[A-Za-z0-9_-]/,
|
|
444
551
|
];
|
|
445
552
|
/** Shannon entropy in bits/char over `s`. */
|
|
446
553
|
function shannonEntropy(s) {
|
|
@@ -465,7 +572,7 @@ function hasHighEntropyToken(text) {
|
|
|
465
572
|
}
|
|
466
573
|
return null;
|
|
467
574
|
}
|
|
468
|
-
/** Every string leaf in
|
|
575
|
+
/** Every string leaf in a claim note, `path` = dotted field name for the refusal. */
|
|
469
576
|
function* noteStringLeaves(note) {
|
|
470
577
|
yield { path: "summary", text: note.summary };
|
|
471
578
|
for (const [group, arr] of [
|
|
@@ -491,9 +598,10 @@ function* noteStringLeaves(note) {
|
|
|
491
598
|
/**
|
|
492
599
|
* Refuse a note that carries a bare secret — no override flag exists
|
|
493
600
|
* (design D10): a note is read by another agent, and secrets have the
|
|
494
|
-
* secrets API. `null` when the note is clean.
|
|
601
|
+
* secrets API. `null` when the note is clean. Shared implementation for
|
|
602
|
+
* every claim-note kind.
|
|
495
603
|
*/
|
|
496
|
-
|
|
604
|
+
function scanNoteForSecrets(note) {
|
|
497
605
|
for (const { path, text } of noteStringLeaves(note)) {
|
|
498
606
|
for (const re of HANDOFF_NOTE_SECRET_PREFIXES) {
|
|
499
607
|
if (re.test(text))
|
|
@@ -505,6 +613,18 @@ export function scanHandoffNoteForSecrets(note) {
|
|
|
505
613
|
}
|
|
506
614
|
return null;
|
|
507
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Refuse a note that carries a bare secret — no override flag exists
|
|
618
|
+
* (design D10): a note is read by another agent, and secrets have the
|
|
619
|
+
* secrets API. `null` when the note is clean.
|
|
620
|
+
*/
|
|
621
|
+
export function scanHandoffNoteForSecrets(note) {
|
|
622
|
+
return scanNoteForSecrets(note);
|
|
623
|
+
}
|
|
624
|
+
/** The invite-kind sibling of {@link scanHandoffNoteForSecrets} (kygit-invite design D3). */
|
|
625
|
+
export function scanInviteNoteForSecrets(note) {
|
|
626
|
+
return scanNoteForSecrets(note);
|
|
627
|
+
}
|
|
508
628
|
/** Throws `HANDOFF_NOTE_CONTAINS_SECRET` naming the field — call before the handoff commit is written. */
|
|
509
629
|
export function assertHandoffNoteHasNoSecret(note) {
|
|
510
630
|
const finding = scanHandoffNoteForSecrets(note);
|
|
@@ -512,4 +632,11 @@ export function assertHandoffNoteHasNoSecret(note) {
|
|
|
512
632
|
fail("HANDOFF_NOTE_CONTAINS_SECRET", `the handoff note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning handoff note", { field: finding.field });
|
|
513
633
|
}
|
|
514
634
|
}
|
|
635
|
+
/** Throws `INVITE_NOTE_CONTAINS_SECRET` naming the field — call before the invite commit is written (kygit-invite design D3). */
|
|
636
|
+
export function assertInviteNoteHasNoSecret(note) {
|
|
637
|
+
const finding = scanInviteNoteForSecrets(note);
|
|
638
|
+
if (finding) {
|
|
639
|
+
fail("INVITE_NOTE_CONTAINS_SECRET", `the invite note's \`${finding.field}\` ${finding.reason} — edit the note; secrets belong in the secrets API, never in a note another agent will read`, "scanning invite note", { field: finding.field });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
515
642
|
//# sourceMappingURL=gitvault-handoff.js.map
|