tryharness 0.7.22 → 0.7.25
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 +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/screens.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { decodeImage } from "./ocr.mjs";
|
|
2
|
+
|
|
3
|
+
// Two screenshots of the same screen, before and after an agent's UI change: which parts of the picture changed, and
|
|
4
|
+
// what the text on screen says now that it did not before. The pixels find where; the text read on this computer
|
|
5
|
+
// (ocr.mjs) finds what, down to a lost space ("yet OPEN" becoming "yetOPEN"). Code only, no model judges: the agent
|
|
6
|
+
// that made the change reads the list against what it was asked to do.
|
|
7
|
+
|
|
8
|
+
const CELL = 16;
|
|
9
|
+
// A cell counts as changed when its pixels differ by more than this on average (0 to 255): re-rendering the same page
|
|
10
|
+
// moves anti-aliasing by a little; the ui-checks eval set it against identical renders.
|
|
11
|
+
const CELL_DIFF = 6;
|
|
12
|
+
const MIN_OVERLAP = 0.3;
|
|
13
|
+
|
|
14
|
+
const luminance = (data, i) =>
|
|
15
|
+
0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
|
16
|
+
const overlap = (a, b) => {
|
|
17
|
+
const w = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])),
|
|
18
|
+
h = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
|
|
19
|
+
const area = (box) => Math.max(1, (box[2] - box[0]) * (box[3] - box[1]));
|
|
20
|
+
return (w * h) / Math.min(area(a), area(b));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Changed regions of two same-size images: [{ box: [x0, y0, x1, y1], share }], largest first. */
|
|
24
|
+
export function changedRegions(
|
|
25
|
+
before,
|
|
26
|
+
after,
|
|
27
|
+
{ cell = CELL, threshold = CELL_DIFF } = {}
|
|
28
|
+
) {
|
|
29
|
+
const cols = Math.ceil(before.width / cell),
|
|
30
|
+
rows = Math.ceil(before.height / cell);
|
|
31
|
+
const changed = new Uint8Array(cols * rows);
|
|
32
|
+
for (let cy = 0; cy < rows; cy += 1)
|
|
33
|
+
for (let cx = 0; cx < cols; cx += 1) {
|
|
34
|
+
let sum = 0,
|
|
35
|
+
count = 0;
|
|
36
|
+
for (
|
|
37
|
+
let y = cy * cell;
|
|
38
|
+
y < Math.min(before.height, (cy + 1) * cell);
|
|
39
|
+
y += 1
|
|
40
|
+
)
|
|
41
|
+
for (
|
|
42
|
+
let x = cx * cell;
|
|
43
|
+
x < Math.min(before.width, (cx + 1) * cell);
|
|
44
|
+
x += 1
|
|
45
|
+
) {
|
|
46
|
+
const i = (y * before.width + x) * 4;
|
|
47
|
+
sum +=
|
|
48
|
+
Math.abs(luminance(before.data, i) - luminance(after.data, i)) +
|
|
49
|
+
Math.abs(before.data[i] - after.data[i]) / 3 +
|
|
50
|
+
Math.abs(before.data[i + 2] - after.data[i + 2]) / 3;
|
|
51
|
+
count += 1;
|
|
52
|
+
}
|
|
53
|
+
if (sum / count > threshold) changed[cy * cols + cx] = 1;
|
|
54
|
+
}
|
|
55
|
+
// Touching changed cells become one region.
|
|
56
|
+
const regions = [];
|
|
57
|
+
const seen = new Uint8Array(cols * rows);
|
|
58
|
+
for (let start = 0; start < changed.length; start += 1) {
|
|
59
|
+
if (!changed[start] || seen[start]) continue;
|
|
60
|
+
const stack = [start];
|
|
61
|
+
seen[start] = 1;
|
|
62
|
+
let x0 = Infinity,
|
|
63
|
+
y0 = Infinity,
|
|
64
|
+
x1 = -1,
|
|
65
|
+
y1 = -1,
|
|
66
|
+
cells = 0;
|
|
67
|
+
while (stack.length) {
|
|
68
|
+
const at = stack.pop(),
|
|
69
|
+
cx = at % cols,
|
|
70
|
+
cy = (at - cx) / cols;
|
|
71
|
+
cells += 1;
|
|
72
|
+
x0 = Math.min(x0, cx);
|
|
73
|
+
y0 = Math.min(y0, cy);
|
|
74
|
+
x1 = Math.max(x1, cx);
|
|
75
|
+
y1 = Math.max(y1, cy);
|
|
76
|
+
for (const [dx, dy] of [
|
|
77
|
+
[1, 0],
|
|
78
|
+
[-1, 0],
|
|
79
|
+
[0, 1],
|
|
80
|
+
[0, -1],
|
|
81
|
+
[1, 1],
|
|
82
|
+
[-1, -1],
|
|
83
|
+
[1, -1],
|
|
84
|
+
[-1, 1],
|
|
85
|
+
]) {
|
|
86
|
+
const nx = cx + dx,
|
|
87
|
+
ny = cy + dy,
|
|
88
|
+
next = ny * cols + nx;
|
|
89
|
+
if (
|
|
90
|
+
nx >= 0 &&
|
|
91
|
+
ny >= 0 &&
|
|
92
|
+
nx < cols &&
|
|
93
|
+
ny < rows &&
|
|
94
|
+
changed[next] &&
|
|
95
|
+
!seen[next]
|
|
96
|
+
) {
|
|
97
|
+
seen[next] = 1;
|
|
98
|
+
stack.push(next);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
regions.push({
|
|
103
|
+
box: [
|
|
104
|
+
x0 * cell,
|
|
105
|
+
y0 * cell,
|
|
106
|
+
Math.min(before.width, (x1 + 1) * cell),
|
|
107
|
+
Math.min(before.height, (y1 + 1) * cell),
|
|
108
|
+
],
|
|
109
|
+
share: Math.round((cells / (cols * rows)) * 10_000) / 10_000,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return regions.sort((a, b) => b.share - a.share);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Lines of text read before and after, paired by where they sit: { changed: [{ before, after, box }], moved, removed, added }. */
|
|
116
|
+
export function textChanges(beforeLines, afterLines) {
|
|
117
|
+
const used = new Set();
|
|
118
|
+
const changed = [],
|
|
119
|
+
removed = [];
|
|
120
|
+
for (const line of beforeLines) {
|
|
121
|
+
const same = afterLines.findIndex(
|
|
122
|
+
(other, i) =>
|
|
123
|
+
!used.has(i) &&
|
|
124
|
+
other.text === line.text &&
|
|
125
|
+
overlap(line.box, other.box) >= MIN_OVERLAP
|
|
126
|
+
);
|
|
127
|
+
if (same >= 0) {
|
|
128
|
+
used.add(same);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let best = -1,
|
|
132
|
+
bestOverlap = MIN_OVERLAP;
|
|
133
|
+
afterLines.forEach((other, i) => {
|
|
134
|
+
const o = overlap(line.box, other.box);
|
|
135
|
+
if (!used.has(i) && o >= bestOverlap) {
|
|
136
|
+
best = i;
|
|
137
|
+
bestOverlap = o;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
if (best >= 0) {
|
|
141
|
+
used.add(best);
|
|
142
|
+
changed.push({
|
|
143
|
+
before: line.text,
|
|
144
|
+
after: afterLines[best].text,
|
|
145
|
+
box: afterLines[best].box,
|
|
146
|
+
});
|
|
147
|
+
} else removed.push({ text: line.text, box: line.box });
|
|
148
|
+
}
|
|
149
|
+
const added = afterLines
|
|
150
|
+
.filter((_, i) => !used.has(i))
|
|
151
|
+
.map((line) => ({ text: line.text, box: line.box }));
|
|
152
|
+
// The same words gone from one place and new in another moved.
|
|
153
|
+
const moved = [];
|
|
154
|
+
for (const line of [...removed]) {
|
|
155
|
+
const to = added.findIndex((other) => other.text === line.text);
|
|
156
|
+
if (to < 0) continue;
|
|
157
|
+
moved.push({ text: line.text, from: line.box, to: added[to].box });
|
|
158
|
+
removed.splice(removed.indexOf(line), 1);
|
|
159
|
+
added.splice(to, 1);
|
|
160
|
+
}
|
|
161
|
+
return { changed, moved, removed, added };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Compares two screenshots (PNG or JPEG bytes): { size, regions, text, summary }. */
|
|
165
|
+
export async function compareScreens(
|
|
166
|
+
beforeBytes,
|
|
167
|
+
afterBytes,
|
|
168
|
+
{ ocr, lane = "interactive" }
|
|
169
|
+
) {
|
|
170
|
+
const before = decodeImage(beforeBytes),
|
|
171
|
+
after = decodeImage(afterBytes);
|
|
172
|
+
if (!before || !after)
|
|
173
|
+
throw new Error("both screenshots must be PNG or JPEG");
|
|
174
|
+
const sameSize =
|
|
175
|
+
before.width === after.width && before.height === after.height;
|
|
176
|
+
const regions = sameSize ? changedRegions(before, after) : [];
|
|
177
|
+
const [readBefore, readAfter] = [
|
|
178
|
+
await ocr.read(before, { lane }),
|
|
179
|
+
await ocr.read(after, { lane }),
|
|
180
|
+
];
|
|
181
|
+
const text = textChanges(readBefore.lines, readAfter.lines);
|
|
182
|
+
const parts = [];
|
|
183
|
+
if (!sameSize)
|
|
184
|
+
parts.push(
|
|
185
|
+
`the screenshots differ in size (${before.width}x${before.height} and ${after.width}x${after.height}), so only their text was compared`
|
|
186
|
+
);
|
|
187
|
+
else
|
|
188
|
+
parts.push(
|
|
189
|
+
regions.length
|
|
190
|
+
? `${regions.length} region${regions.length === 1 ? "" : "s"} of the picture changed`
|
|
191
|
+
: "no part of the picture changed"
|
|
192
|
+
);
|
|
193
|
+
for (const change of text.changed)
|
|
194
|
+
parts.push(`"${change.before}" now reads "${change.after}"`);
|
|
195
|
+
for (const line of text.moved) parts.push(`"${line.text}" moved`);
|
|
196
|
+
if (text.removed.length)
|
|
197
|
+
parts.push(
|
|
198
|
+
`gone: ${text.removed.map((line) => `"${line.text}"`).join(", ")}`
|
|
199
|
+
);
|
|
200
|
+
if (text.added.length)
|
|
201
|
+
parts.push(`new: ${text.added.map((line) => `"${line.text}"`).join(", ")}`);
|
|
202
|
+
return {
|
|
203
|
+
size: {
|
|
204
|
+
before: [before.width, before.height],
|
|
205
|
+
after: [after.width, after.height],
|
|
206
|
+
},
|
|
207
|
+
regions: regions.slice(0, 20),
|
|
208
|
+
text,
|
|
209
|
+
summary: `${parts.join("; ")}.`,
|
|
210
|
+
ms: readBefore.ms + readAfter.ms,
|
|
211
|
+
};
|
|
212
|
+
}
|
package/lib/sealed.mjs
CHANGED
|
@@ -13,6 +13,9 @@ import { timingSafeEqual, webcrypto } from "node:crypto";
|
|
|
13
13
|
// - Every message: AES-256-GCM under HKDF-SHA256(ECDH(device, daemon)), a fresh 96-bit nonce,
|
|
14
14
|
// and the direction, device and request id bound as associated data. Requests carry a
|
|
15
15
|
// timestamp; the daemon refuses stale ones and any request id it has already seen.
|
|
16
|
+
// - The other way round, the daemon asks a paired browser (its screen history): the question goes
|
|
17
|
+
// "d2b" and the answer "b2d" under the same key, so neither opens as a message of the device's
|
|
18
|
+
// own calls. The browser applies the same freshness and replay rules (phones.mjs ask).
|
|
16
19
|
// Not provided: forward secrecy (both keys are static). Revoking a device ends its key.
|
|
17
20
|
|
|
18
21
|
const subtle = webcrypto.subtle;
|
|
@@ -23,57 +26,135 @@ export const SEALED_VERSION = 1;
|
|
|
23
26
|
export const MAX_SKEW_MS = 5 * 60_000;
|
|
24
27
|
|
|
25
28
|
export const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
26
|
-
export const fromB64u = (text) =>
|
|
29
|
+
export const fromB64u = (text) =>
|
|
30
|
+
new Uint8Array(Buffer.from(String(text), "base64url"));
|
|
27
31
|
|
|
28
32
|
export async function loadOrCreateIdentity(file) {
|
|
29
33
|
try {
|
|
30
34
|
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
31
|
-
const privateKey = await subtle.importKey(
|
|
35
|
+
const privateKey = await subtle.importKey(
|
|
36
|
+
"jwk",
|
|
37
|
+
saved.privateJwk,
|
|
38
|
+
CURVE,
|
|
39
|
+
false,
|
|
40
|
+
["deriveBits"]
|
|
41
|
+
);
|
|
32
42
|
return { privateKey, publicKey: saved.publicKey };
|
|
33
43
|
} catch (error) {
|
|
34
|
-
if (error.code !== "ENOENT")
|
|
44
|
+
if (error.code !== "ENOENT")
|
|
45
|
+
throw new Error(
|
|
46
|
+
`the phone identity at ${file} is unreadable: ${error.message}`
|
|
47
|
+
);
|
|
35
48
|
}
|
|
36
49
|
const pair = await subtle.generateKey(CURVE, true, ["deriveBits"]);
|
|
37
|
-
const publicKey = b64u(
|
|
50
|
+
const publicKey = b64u(
|
|
51
|
+
new Uint8Array(await subtle.exportKey("raw", pair.publicKey))
|
|
52
|
+
);
|
|
38
53
|
const privateJwk = await subtle.exportKey("jwk", pair.privateKey);
|
|
39
|
-
fs.writeFileSync(
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
file,
|
|
56
|
+
JSON.stringify({ version: SEALED_VERSION, publicKey, privateJwk }),
|
|
57
|
+
{ mode: 0o600, flag: "wx" }
|
|
58
|
+
);
|
|
40
59
|
return loadOrCreateIdentity(file);
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
export async function pairProof(
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
export async function pairProof(
|
|
63
|
+
secret,
|
|
64
|
+
offerId,
|
|
65
|
+
devicePublicKey,
|
|
66
|
+
daemonPublicKey
|
|
67
|
+
) {
|
|
68
|
+
const key = await subtle.importKey(
|
|
69
|
+
"raw",
|
|
70
|
+
fromB64u(secret),
|
|
71
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
72
|
+
false,
|
|
73
|
+
["sign"]
|
|
74
|
+
);
|
|
75
|
+
return b64u(
|
|
76
|
+
new Uint8Array(
|
|
77
|
+
await subtle.sign(
|
|
78
|
+
"HMAC",
|
|
79
|
+
key,
|
|
80
|
+
encoder.encode(
|
|
81
|
+
`HSC1-pair\n${offerId}\n${devicePublicKey}\n${daemonPublicKey}`
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
);
|
|
46
86
|
}
|
|
47
87
|
|
|
48
88
|
export async function channelKey(privateKey, peerPublicKey, deviceId) {
|
|
49
|
-
const peer = await subtle.importKey(
|
|
50
|
-
|
|
51
|
-
|
|
89
|
+
const peer = await subtle.importKey(
|
|
90
|
+
"raw",
|
|
91
|
+
fromB64u(peerPublicKey),
|
|
92
|
+
CURVE,
|
|
93
|
+
false,
|
|
94
|
+
[]
|
|
95
|
+
);
|
|
96
|
+
const shared = await subtle.deriveBits(
|
|
97
|
+
{ name: "ECDH", public: peer },
|
|
98
|
+
privateKey,
|
|
99
|
+
256
|
|
100
|
+
);
|
|
101
|
+
const material = await subtle.importKey("raw", shared, "HKDF", false, [
|
|
102
|
+
"deriveKey",
|
|
103
|
+
]);
|
|
52
104
|
return subtle.deriveKey(
|
|
53
|
-
{
|
|
54
|
-
|
|
105
|
+
{
|
|
106
|
+
name: "HKDF",
|
|
107
|
+
hash: "SHA-256",
|
|
108
|
+
salt: encoder.encode("HSC1"),
|
|
109
|
+
info: encoder.encode(`HSC1 channel ${deviceId}`),
|
|
110
|
+
},
|
|
111
|
+
material,
|
|
112
|
+
{ name: "AES-GCM", length: 256 },
|
|
113
|
+
false,
|
|
114
|
+
["encrypt", "decrypt"]
|
|
55
115
|
);
|
|
56
116
|
}
|
|
57
117
|
|
|
58
|
-
const aad = (direction, deviceId, requestId) =>
|
|
118
|
+
const aad = (direction, deviceId, requestId) =>
|
|
119
|
+
encoder.encode(`HSC1|${direction}|${deviceId}|${requestId}`);
|
|
59
120
|
|
|
60
121
|
export async function seal(key, direction, deviceId, requestId, value) {
|
|
61
122
|
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
62
|
-
const ct = await subtle.encrypt(
|
|
63
|
-
|
|
123
|
+
const ct = await subtle.encrypt(
|
|
124
|
+
{
|
|
125
|
+
name: "AES-GCM",
|
|
126
|
+
iv,
|
|
127
|
+
additionalData: aad(direction, deviceId, requestId),
|
|
128
|
+
},
|
|
129
|
+
key,
|
|
130
|
+
encoder.encode(JSON.stringify(value))
|
|
131
|
+
);
|
|
132
|
+
return {
|
|
133
|
+
v: SEALED_VERSION,
|
|
134
|
+
deviceId,
|
|
135
|
+
requestId,
|
|
136
|
+
iv: b64u(iv),
|
|
137
|
+
ct: b64u(new Uint8Array(ct)),
|
|
138
|
+
};
|
|
64
139
|
}
|
|
65
140
|
|
|
66
141
|
// Throws on a wrong key, a wrong direction, device or request id, or any changed byte.
|
|
67
142
|
export async function open(key, direction, envelope) {
|
|
68
143
|
const plain = await subtle.decrypt(
|
|
69
|
-
{
|
|
70
|
-
|
|
144
|
+
{
|
|
145
|
+
name: "AES-GCM",
|
|
146
|
+
iv: fromB64u(envelope.iv),
|
|
147
|
+
additionalData: aad(direction, envelope.deviceId, envelope.requestId),
|
|
148
|
+
},
|
|
149
|
+
key,
|
|
150
|
+
fromB64u(envelope.ct)
|
|
71
151
|
);
|
|
72
152
|
return JSON.parse(decoder.decode(plain));
|
|
73
153
|
}
|
|
74
154
|
|
|
75
155
|
export function constantTimeEqual(a, b) {
|
|
76
|
-
const left = Buffer.from(String(a)),
|
|
156
|
+
const left = Buffer.from(String(a)),
|
|
157
|
+
right = Buffer.from(String(b));
|
|
77
158
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
78
159
|
}
|
|
79
160
|
|
|
@@ -82,31 +163,82 @@ export function constantTimeEqual(a, b) {
|
|
|
82
163
|
// associated data. A peer's key comes from Harness's directory the first time and is pinned by
|
|
83
164
|
// handle on this machine; a different key later is refused until the person trusts it.
|
|
84
165
|
export async function relayKey(privateKey, peerPublicKey) {
|
|
85
|
-
const peer = await subtle.importKey(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
166
|
+
const peer = await subtle.importKey(
|
|
167
|
+
"raw",
|
|
168
|
+
fromB64u(peerPublicKey),
|
|
169
|
+
CURVE,
|
|
170
|
+
false,
|
|
171
|
+
[]
|
|
172
|
+
);
|
|
173
|
+
const shared = await subtle.deriveBits(
|
|
174
|
+
{ name: "ECDH", public: peer },
|
|
175
|
+
privateKey,
|
|
176
|
+
256
|
|
177
|
+
);
|
|
178
|
+
const material = await subtle.importKey("raw", shared, "HKDF", false, [
|
|
179
|
+
"deriveKey",
|
|
180
|
+
]);
|
|
181
|
+
return subtle.deriveKey(
|
|
182
|
+
{
|
|
183
|
+
name: "HKDF",
|
|
184
|
+
hash: "SHA-256",
|
|
185
|
+
salt: encoder.encode("HSC1"),
|
|
186
|
+
info: encoder.encode("HSC1 relay"),
|
|
187
|
+
},
|
|
188
|
+
material,
|
|
189
|
+
{ name: "AES-GCM", length: 256 },
|
|
190
|
+
false,
|
|
191
|
+
["encrypt", "decrypt"]
|
|
192
|
+
);
|
|
89
193
|
}
|
|
90
194
|
export async function sealBound(key, bound, value) {
|
|
91
195
|
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
92
|
-
const ct = await subtle.encrypt(
|
|
196
|
+
const ct = await subtle.encrypt(
|
|
197
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(`HSC1|${bound}`) },
|
|
198
|
+
key,
|
|
199
|
+
encoder.encode(JSON.stringify(value))
|
|
200
|
+
);
|
|
93
201
|
return { iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
|
|
94
202
|
}
|
|
95
203
|
export async function openBound(key, bound, sealed) {
|
|
96
|
-
const plain = await subtle.decrypt(
|
|
204
|
+
const plain = await subtle.decrypt(
|
|
205
|
+
{
|
|
206
|
+
name: "AES-GCM",
|
|
207
|
+
iv: fromB64u(sealed.iv),
|
|
208
|
+
additionalData: encoder.encode(`HSC1|${bound}`),
|
|
209
|
+
},
|
|
210
|
+
key,
|
|
211
|
+
fromB64u(sealed.ct)
|
|
212
|
+
);
|
|
97
213
|
return JSON.parse(decoder.decode(plain));
|
|
98
214
|
}
|
|
99
215
|
// A file's bytes, sealed as they are (no JSON or base64 inside): bound to its thread and file id like a message.
|
|
100
216
|
export async function sealBytes(key, bound, bytes) {
|
|
101
217
|
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
102
|
-
const ct = await subtle.encrypt(
|
|
218
|
+
const ct = await subtle.encrypt(
|
|
219
|
+
{ name: "AES-GCM", iv, additionalData: encoder.encode(`HSC1|${bound}`) },
|
|
220
|
+
key,
|
|
221
|
+
bytes
|
|
222
|
+
);
|
|
103
223
|
return { iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
|
|
104
224
|
}
|
|
105
225
|
export async function openBytes(key, bound, sealed) {
|
|
106
|
-
return Buffer.from(
|
|
226
|
+
return Buffer.from(
|
|
227
|
+
await subtle.decrypt(
|
|
228
|
+
{
|
|
229
|
+
name: "AES-GCM",
|
|
230
|
+
iv: fromB64u(sealed.iv),
|
|
231
|
+
additionalData: encoder.encode(`HSC1|${bound}`),
|
|
232
|
+
},
|
|
233
|
+
key,
|
|
234
|
+
fromB64u(sealed.ct)
|
|
235
|
+
)
|
|
236
|
+
);
|
|
107
237
|
}
|
|
108
238
|
// What two people read to each other to check they hold each other's key: SHA-256 of the key, 20 hex digits in groups of four.
|
|
109
239
|
export async function fingerprint(publicKey) {
|
|
110
|
-
const digest = Buffer.from(
|
|
240
|
+
const digest = Buffer.from(
|
|
241
|
+
await subtle.digest("SHA-256", fromB64u(publicKey))
|
|
242
|
+
).toString("hex");
|
|
111
243
|
return digest.slice(0, 20).match(/.{4}/g).join(" ");
|
|
112
244
|
}
|
package/lib/secrets.mjs
CHANGED
|
@@ -8,18 +8,31 @@ const PATTERNS = [
|
|
|
8
8
|
{ reason: "a private key block", test: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
9
9
|
{ reason: "an OpenAI-style API key", test: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
10
10
|
{ reason: "an AWS access key", test: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
11
|
-
{
|
|
11
|
+
{
|
|
12
|
+
reason: "a GitHub token",
|
|
13
|
+
test: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b/,
|
|
14
|
+
},
|
|
12
15
|
{ reason: "a Slack token", test: /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/ },
|
|
13
|
-
{
|
|
16
|
+
{
|
|
17
|
+
reason: "a JSON web token",
|
|
18
|
+
test: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
|
|
19
|
+
},
|
|
14
20
|
{ reason: "a 32-byte hex key", test: /\b(0x)?[0-9a-fA-F]{64}\b/ },
|
|
15
21
|
{ reason: "a Harness connection token", test: /\bhmcp_[A-Za-z0-9]{12,}\b/ },
|
|
16
|
-
{
|
|
17
|
-
|
|
22
|
+
{
|
|
23
|
+
reason: "a secret assigned in env style",
|
|
24
|
+
test: /\b[A-Z0-9_]*(SECRET|TOKEN|PASSWORD|PRIVATE_KEY|API_KEY)[A-Z0-9_]*\s*[=:]\s*['"]?[^\s'"]{8,}/,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
reason: "a seed phrase",
|
|
28
|
+
test: /\b(seed phrase|mnemonic|recovery phrase)\b[\s\S]{0,40}?(\b[a-z]{3,8}\b[\s,]+){11,}/i,
|
|
29
|
+
},
|
|
18
30
|
];
|
|
19
31
|
|
|
20
32
|
/** The reason a text must be held, or null when nothing in it looks like a secret. */
|
|
21
33
|
export function secretReason(text) {
|
|
22
34
|
const value = String(text || "");
|
|
23
|
-
for (const pattern of PATTERNS)
|
|
35
|
+
for (const pattern of PATTERNS)
|
|
36
|
+
if (pattern.test.test(value)) return pattern.reason;
|
|
24
37
|
return null;
|
|
25
38
|
}
|