secure-agents 0.0.0-stage → 0.7.2

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/guard.js ADDED
@@ -0,0 +1,98 @@
1
+ // What may leave this computer: files only from the folders (or whole computer) the person allowed, never through symlinks out of them. Secret-looking files need the person's review.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { HOME } from './config.js';
7
+ import { SECRETS as SECRET_PATTERNS } from './scan.js';
8
+ const SENSITIVE_NAMES = [/^\.env(\..*)?$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /\.pfx$/i, /^id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i, /credentials/i, /secret/i, /\.kdbx$/i, /^\.npmrc$/i, /^\.netrc$/i, /^\.pgpass$/i];
9
+ const SENSITIVE_DIRS = new Set(['.ssh', '.aws', '.gnupg', '.kube', '.docker', '.azure', '.config/gcloud']);
10
+ /** Secure's own data (keys, vault, messages) never leaves, whatever the person allowed. */
11
+ const secureHome = () => { try {
12
+ return fs.realpathSync(HOME);
13
+ }
14
+ catch {
15
+ return path.resolve(HOME);
16
+ } };
17
+ const isSecureData = (real) => { const h = secureHome(); return real === h || real.startsWith(h + path.sep); };
18
+ /** Resolves a path inside one of the shared folders and checks it; never follows a link outside them. */
19
+ export function checkFile(requested, folders, maxBytes, wholeComputer = false) {
20
+ const fail = (reason, abs = '') => ({ ok: false, abs, bytes: 0, sha256: '', flags: [], reason });
21
+ if (!folders.length)
22
+ return fail('No folders are shared with this agent, and it does not have access to the whole computer.');
23
+ let inside = false;
24
+ for (const folder of folders) {
25
+ const root = fs.realpathSync(folder);
26
+ const candidate = path.isAbsolute(requested) ? requested : path.join(root, requested);
27
+ if (path.resolve(candidate).startsWith(root + path.sep))
28
+ inside = true;
29
+ let real;
30
+ try {
31
+ real = fs.realpathSync(candidate);
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ if (real !== root && !real.startsWith(root + path.sep))
37
+ continue;
38
+ if (isSecureData(real))
39
+ return fail("That is Secure's own data (keys and messages); it never leaves this computer.", real);
40
+ const rel = path.relative(os.homedir(), real).split(path.sep).join('/');
41
+ const name = path.basename(real);
42
+ const st = fs.statSync(real);
43
+ if (!st.isFile())
44
+ return fail('That path is a folder, not a file.', real);
45
+ if (st.size > maxBytes)
46
+ return fail(`The file is ${mb(st.size)}, over this computer's limit of ${mb(maxBytes)}.`, real);
47
+ const flags = [];
48
+ if ([...SENSITIVE_DIRS].some((d) => rel === d || rel.startsWith(`${d}/`) || rel.includes(`/${d}/`)))
49
+ flags.push('is inside a folder that holds keys or logins');
50
+ if (SENSITIVE_NAMES.some((r) => r.test(name)))
51
+ flags.push(`"${name}" looks like a secrets file`);
52
+ const found = scanSecrets(real);
53
+ if (found.length)
54
+ flags.push(`contains what looks like a secret (${found.join(', ')})`);
55
+ return { ok: true, abs: real, bytes: st.size, sha256: hashFile(real), flags };
56
+ }
57
+ if (wholeComputer)
58
+ return fail(`No file at "${requested}" on that computer. Relative paths start at its home folder (~); a full path also works.`);
59
+ const list = folders.map(tilde).join(', ');
60
+ return fail(inside ? `No file at "${requested}" in the folders shared with you (${list}). Check the path; it is relative to one of them.` : `"${requested}" is outside the folders shared with you (${list}).`);
61
+ }
62
+ /** Checks a file this agent itself offers; the same rules, without the shared-folder rule. */
63
+ export function checkOwnFile(p, maxBytes) {
64
+ const abs = path.resolve(p);
65
+ if (!fs.existsSync(abs))
66
+ return { ok: false, abs, bytes: 0, sha256: '', flags: [], reason: 'That file does not exist.' };
67
+ return checkFile(path.basename(abs), [path.dirname(abs)], maxBytes);
68
+ }
69
+ function scanSecrets(p) {
70
+ const fd = fs.openSync(p, 'r');
71
+ try {
72
+ const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 2 * 1024 * 1024));
73
+ fs.readSync(fd, buf, 0, buf.length, 0);
74
+ if (buf.includes(0))
75
+ return [];
76
+ const text = buf.toString('utf8');
77
+ return SECRET_PATTERNS.filter(([, r]) => r.test(text)).map(([n]) => n);
78
+ }
79
+ finally {
80
+ fs.closeSync(fd);
81
+ }
82
+ }
83
+ export function hashFile(p) {
84
+ const h = crypto.createHash('sha256');
85
+ const fd = fs.openSync(p, 'r');
86
+ try {
87
+ const buf = Buffer.alloc(1024 * 1024);
88
+ let n;
89
+ while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0)
90
+ h.update(buf.subarray(0, n));
91
+ }
92
+ finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ return h.digest('hex');
96
+ }
97
+ const tilde = (p) => p === os.homedir() || p.startsWith(os.homedir() + path.sep) ? `~${p.slice(os.homedir().length)}` : p;
98
+ export const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
@@ -0,0 +1,38 @@
1
+ // This computer's key pair: its identity on the network. The secret key never leaves the machine and is stored locked by the passphrase.
2
+ import crypto from 'node:crypto';
3
+ import HyperDHT from 'hyperdht';
4
+ import { readSealed, writeSealed } from './vault.js';
5
+ export function loadOrCreateIdentity() {
6
+ const saved = readSealed('identity.sealed', null);
7
+ if (saved)
8
+ return { publicKey: Buffer.from(saved.publicKey, 'hex'), secretKey: Buffer.from(saved.secretKey, 'hex') };
9
+ return newIdentity();
10
+ }
11
+ /** A fresh key pair: every paired agent sees a new computer and must pair again. */
12
+ export function newIdentity() {
13
+ const kp = HyperDHT.keyPair();
14
+ writeSealed('identity.sealed', { publicKey: kp.publicKey.toString('hex'), secretKey: kp.secretKey.toString('hex') });
15
+ return kp;
16
+ }
17
+ /** Short number both people compare when pairing; the same on both screens only if no one is in the middle. */
18
+ export function safetyNumber(a, b) {
19
+ const h = crypto.createHash('sha256').update([a, b].sort().join(':')).digest();
20
+ const digits = Array.from({ length: 4 }, (_, i) => String(h.readUInt32BE(i * 4) % 100000).padStart(5, '0'));
21
+ return digits.join(' ');
22
+ }
23
+ // Receipts are signed with this computer's Ed25519 key (Hyperswarm keys are Ed25519: 32-byte seed + 32-byte public key).
24
+ const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex');
25
+ const SPKI = Buffer.from('302a300506032b6570032100', 'hex');
26
+ export function sign(kp, payload) {
27
+ const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8, kp.secretKey.subarray(0, 32)]), format: 'der', type: 'pkcs8' });
28
+ return crypto.sign(null, Buffer.from(payload), key).toString('hex');
29
+ }
30
+ export function verify(publicKeyHex, payload, signatureHex) {
31
+ try {
32
+ const key = crypto.createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(publicKeyHex, 'hex')]), format: 'der', type: 'spki' });
33
+ return crypto.verify(null, Buffer.from(payload), key, Buffer.from(signatureHex, 'hex'));
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
package/dist/invite.js ADDED
@@ -0,0 +1,52 @@
1
+ // Invite codes: the inviter's public key plus a one-time secret, and the relay it uses if it hides its IP; typed or pasted by the other person.
2
+ import crypto from 'node:crypto';
3
+ const PREFIX = 'sec1-';
4
+ const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
5
+ function base32(buf) {
6
+ let bits = 0, value = 0, out = '';
7
+ for (const byte of buf) {
8
+ value = (value << 8) | byte;
9
+ bits += 8;
10
+ while (bits >= 5) {
11
+ out += ALPHABET[(value >>> (bits - 5)) & 31];
12
+ bits -= 5;
13
+ }
14
+ }
15
+ if (bits > 0)
16
+ out += ALPHABET[(value << (5 - bits)) & 31];
17
+ return out;
18
+ }
19
+ function unbase32(s) {
20
+ let bits = 0, value = 0;
21
+ const out = [];
22
+ for (const ch of s) {
23
+ const v = ALPHABET.indexOf(ch);
24
+ if (v < 0)
25
+ throw new Error('This invite code has a typo.');
26
+ value = (value << 5) | v;
27
+ bits += 5;
28
+ if (bits >= 8) {
29
+ out.push((value >>> (bits - 8)) & 255);
30
+ bits -= 8;
31
+ }
32
+ }
33
+ return Buffer.from(out);
34
+ }
35
+ export function newSecret() { return crypto.randomBytes(16).toString('hex'); }
36
+ export function encodeInvite(publicKey, secret, relay) {
37
+ return PREFIX + base32(Buffer.concat([Buffer.from(publicKey, 'hex'), Buffer.from(secret, 'hex'), relay ? Buffer.from(relay, 'hex') : Buffer.alloc(0)]));
38
+ }
39
+ export function decodeInvite(code) {
40
+ const c = code.trim().toLowerCase().replace(/\s+/g, '');
41
+ if (!c.startsWith(PREFIX))
42
+ throw new Error('That is not a Secure invite code (it should start with "sec1-").');
43
+ const raw = unbase32(c.slice(PREFIX.length));
44
+ if (raw.length !== 48 && raw.length !== 80)
45
+ throw new Error('This invite code is incomplete.');
46
+ return { publicKey: raw.subarray(0, 32).toString('hex'), secret: raw.subarray(32, 48).toString('hex'), relay: raw.length === 80 ? raw.subarray(48).toString('hex') : null };
47
+ }
48
+ /** Constant-time comparison for invite secrets. */
49
+ export function sameSecret(a, b) {
50
+ const x = Buffer.from(a, 'hex'), y = Buffer.from(b, 'hex');
51
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
52
+ }