@sharedenv/cli 0.0.0-stage → 1.0.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/lib/auth.js ADDED
@@ -0,0 +1,70 @@
1
+ // Signing in and unlocking the vault. The master password never leaves this process:
2
+ // the server receives a verifier derived from it, and the private key is unwrapped
3
+ // locally from the keyring the server stores sealed.
4
+
5
+ import { Wallet, assertKdfParams, deriveAuthVerifier, unwrapPrivateKey } from './crypto.js';
6
+ import { expectOk } from './api.js';
7
+
8
+ const wallet = new Wallet();
9
+
10
+ // `authKdf` comes from the server: the verifier is domain-separated from the wrap key,
11
+ // so a server that serves the keyring's `kdf` as `authKdf` still never receives the key
12
+ // that unwraps the vault.
13
+ //
14
+ // `otp` answers the second factor when the account has one: a code, or an async function
15
+ // returning one. Any code the API accepts works — authenticator, mail or recovery code.
16
+ export async function login(client, { identifier, password, otp }) {
17
+ const pre = expectOk(
18
+ await client.call('GET', `/auth/prelogin?identifier=${encodeURIComponent(identifier)}`),
19
+ 'cannot sign in'
20
+ );
21
+ if (!pre?.authKdf) throw new Error('cannot sign in: the server sent no login parameters');
22
+ const authVerifier = await deriveAuthVerifier(password, pre.authKdf);
23
+ const session = expectOk(
24
+ await client.call('POST', '/auth/sign-in', { identifier, authVerifier }),
25
+ 'cannot sign in'
26
+ );
27
+ if (session?.use2fa) {
28
+ const code = typeof otp === 'function' ? await otp() : otp;
29
+ if (!code) throw new Error('this account requires a two-factor code');
30
+ expectOk(
31
+ await client.call('POST', '/auth/2fa/validate', { code: String(code).trim() }),
32
+ 'two-factor code refused'
33
+ );
34
+ }
35
+ return session;
36
+ }
37
+
38
+ // Our public key, DERIVED from the private key we hold — never the server's copy.
39
+ export function publicKeyOf(privateKey) {
40
+ return wallet.keypass.publicFromPrivate(privateKey);
41
+ }
42
+
43
+ // → the account's private key, in memory only.
44
+ export async function unlockPrivateKey(client, password) {
45
+ const keyring = expectOk(await client.call('GET', '/keyring/me'), 'cannot read your keyring');
46
+ if (!keyring?.encPrivateKey) {
47
+ throw new Error('this account has no keyring yet: open the web app once to create it');
48
+ }
49
+ // The KDF parameters come from the server. Below the floor, the password would be
50
+ // stretched by almost nothing — an offline attack, not a typo: say so.
51
+ try {
52
+ assertKdfParams(keyring.kdf);
53
+ } catch (err) {
54
+ throw new Error(
55
+ `refusing to unlock: the server sent key-derivation parameters below the accepted floor (${err.message})`
56
+ );
57
+ }
58
+ let privateKey;
59
+ try {
60
+ privateKey = unwrapPrivateKey(keyring.encPrivateKey, password, keyring.kdf);
61
+ } catch {
62
+ throw new Error('wrong password — could not unlock the vault');
63
+ }
64
+ // The directory must name the key we hold: a row naming another public key would let
65
+ // the server choose who receives what is sealed to "us".
66
+ if (String(keyring.publicKey || '').toLowerCase() !== publicKeyOf(privateKey)) {
67
+ throw new Error('the public key on file is not the one of your private key — refusing');
68
+ }
69
+ return privateKey;
70
+ }
@@ -0,0 +1,35 @@
1
+ // AES-256-GCM helper — the symmetric primitive shared by the framework's
2
+ // zero-knowledge crypto: private-key wrap under the KDF key, secret values under
3
+ // a DEK, and the ECIES inner cipher in keypass. Envelopes are emitted as hex so
4
+ // they serialize cleanly into opaque `json` Vault fields.
5
+ //
6
+ // Isomorphic note: this uses Node's `crypto`. A browser bundle swaps this one
7
+ // file for a WebCrypto/@noble implementation with the same { ciphertext, iv,
8
+ // authTag } hex contract — nothing else in the lib changes.
9
+
10
+ import crypto from 'crypto';
11
+
12
+ const IV_BYTES = 12; // 96-bit nonce, the GCM standard
13
+
14
+ // key: Buffer(32). data: Buffer. aad (optional): Buffer, authenticated, not encrypted.
15
+ // → { ciphertext, iv, authTag } as hex strings.
16
+ export function gcmEncrypt(key, data, aad) {
17
+ const iv = crypto.randomBytes(IV_BYTES);
18
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
19
+ if (aad) cipher.setAAD(aad);
20
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
21
+ return {
22
+ ciphertext: ciphertext.toString('hex'),
23
+ iv: iv.toString('hex'),
24
+ authTag: cipher.getAuthTag().toString('hex'),
25
+ };
26
+ }
27
+
28
+ // key: Buffer(32). envelope: { ciphertext, iv, authTag } hex. aad: as sealed. → Buffer.
29
+ // Throws if the auth tag does not verify (wrong key, tampered ciphertext or other aad).
30
+ export function gcmDecrypt(key, { ciphertext, iv, authTag }, aad) {
31
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
32
+ if (aad) decipher.setAAD(aad);
33
+ decipher.setAuthTag(Buffer.from(authTag, 'hex'));
34
+ return Buffer.concat([decipher.update(Buffer.from(ciphertext, 'hex')), decipher.final()]);
35
+ }
@@ -0,0 +1,15 @@
1
+ // A short, comparable digest of a PUBLIC key — what two people read to each other to
2
+ // check they hold the same one. The server is the key directory, and a directory can
3
+ // lie: clients pin the keys they have seen and show these digests when one changes.
4
+ // SHA-256 of the key bytes, first 16 bytes, uppercase hex, grouped by 4 — every client
5
+ // computes the same characters for the same key.
6
+
7
+ import crypto from 'crypto';
8
+
9
+ export function fingerprintOf(publicKeyHex) {
10
+ const digest = crypto
11
+ .createHash('sha256')
12
+ .update(Buffer.from(String(publicKeyHex).toLowerCase(), 'hex'))
13
+ .digest('hex');
14
+ return digest.slice(0, 32).toUpperCase().match(/.{4}/g).join(' ');
15
+ }
@@ -0,0 +1,158 @@
1
+ // KDF + private-key wrapping. The master password is stretched with scrypt into
2
+ // a 32-byte key that wraps (AES-256-GCM) a secp256k1 private key. Only the
3
+ // wrapped blob + the (public) KDF params reach the server; the password and the
4
+ // derived key never leave the client.
5
+ //
6
+ // Runs CLIENT-SIDE. A server storing `encPrivateKey` + `kdf` can never
7
+ // reconstruct the key without the password.
8
+
9
+ import crypto from 'crypto';
10
+ import { promisify } from 'util';
11
+ import { gcmEncrypt, gcmDecrypt } from './aes.js';
12
+
13
+ // scrypt N=2^15 needs ~32MB; bump maxmem so Node doesn't refuse the cost.
14
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
15
+
16
+ export function defaultKdfParams() {
17
+ return {
18
+ algo: 'scrypt',
19
+ salt: crypto.randomBytes(16).toString('hex'),
20
+ N: 32768,
21
+ r: 8,
22
+ p: 1,
23
+ keylen: 32,
24
+ };
25
+ }
26
+
27
+ // ── KDF floor — the downgrade guard ───────────────────────────────────────────
28
+ // A client derives under params it did NOT choose: `authKdf` comes from
29
+ // `GET /auth/prelogin`, `kdf` from the keyring row — both SERVER-supplied. Without
30
+ // a floor, a compromised (or impersonated) server answers `{ N: 2 }`, receives a
31
+ // verifier stretched by almost nothing, brute-forces the master password offline,
32
+ // and then unwraps `encPrivateKey` under the REAL params. That turns "the server
33
+ // was breached" into "every vault whose owner logged in meanwhile is plaintext" —
34
+ // the exact event zero-knowledge exists to survive.
35
+ //
36
+ // So the floor is enforced where the password is stretched, not where params are
37
+ // stored: `deriveKey` refuses anything weaker than what `defaultKdfParams` mints.
38
+ // Raising the defaults later means raising this floor with them (older rows then
39
+ // need a re-wrap, which is what `POST /keyring/rotate` is for).
40
+ export const KDF_FLOOR = Object.freeze({
41
+ scrypt: Object.freeze({ N: 32768, r: 8, p: 1 }),
42
+ pbkdf2: Object.freeze({ iterations: 600000, digests: Object.freeze(['sha256', 'sha512']) }),
43
+ saltBytes: 16,
44
+ keylen: 32,
45
+ });
46
+
47
+ const isInt = (n) => Number.isInteger(n) && n > 0;
48
+
49
+ // Throws on params a client must refuse to stretch a password under. Pure — also
50
+ // used server-side to reject weak params at write time (sharedenv keyring).
51
+ export function assertKdfParams(params) {
52
+ if (!params || typeof params !== 'object') throw new Error('KDF params missing');
53
+ const { algo = 'scrypt', salt, keylen = KDF_FLOOR.keylen } = params;
54
+
55
+ if (typeof salt !== 'string' || !/^([0-9a-fA-F]{2})+$/.test(salt)) {
56
+ throw new Error('KDF salt must be a hex string');
57
+ }
58
+ if (salt.length / 2 < KDF_FLOOR.saltBytes) {
59
+ throw new Error(`KDF salt too short (< ${KDF_FLOOR.saltBytes} bytes)`);
60
+ }
61
+ if (keylen !== KDF_FLOOR.keylen) throw new Error(`KDF keylen must be ${KDF_FLOOR.keylen}`);
62
+
63
+ if (algo === 'scrypt') {
64
+ const { N = KDF_FLOOR.scrypt.N, r = KDF_FLOOR.scrypt.r, p = KDF_FLOOR.scrypt.p } = params;
65
+ if (!isInt(N) || (N & (N - 1)) !== 0) throw new Error('scrypt N must be a power of two');
66
+ if (N < KDF_FLOOR.scrypt.N || !isInt(r) || r < KDF_FLOOR.scrypt.r || !isInt(p)) {
67
+ throw new Error('scrypt params below the accepted floor');
68
+ }
69
+ return;
70
+ }
71
+ if (algo === 'pbkdf2') {
72
+ const { iterations = KDF_FLOOR.pbkdf2.iterations, digest = 'sha256' } = params;
73
+ if (!isInt(iterations) || iterations < KDF_FLOOR.pbkdf2.iterations) {
74
+ throw new Error('pbkdf2 iterations below the accepted floor');
75
+ }
76
+ if (!KDF_FLOOR.pbkdf2.digests.includes(digest)) throw new Error('pbkdf2 digest not accepted');
77
+ return;
78
+ }
79
+ throw new Error(`Unknown KDF algo "${algo}"`);
80
+ }
81
+
82
+ // → [node:crypto function name, its arguments], shared by both flavours below.
83
+ // Refuses params below `KDF_FLOOR` (see above) before stretching anything.
84
+ function kdfCall(password, params) {
85
+ assertKdfParams(params);
86
+ const { algo, salt, keylen = 32 } = params;
87
+ const saltBuf = Buffer.from(salt, 'hex');
88
+
89
+ if (algo === 'pbkdf2') {
90
+ const { iterations = 600000, digest = 'sha256' } = params;
91
+ return ['pbkdf2', [password, saltBuf, iterations, keylen, digest]];
92
+ }
93
+
94
+ // default: scrypt
95
+ const { N = 32768, r = 8, p = 1 } = params;
96
+ return ['scrypt', [password, saltBuf, keylen, { N, r, p, maxmem: SCRYPT_MAXMEM }]];
97
+ }
98
+
99
+ const ASYNC_KDF = { pbkdf2: promisify(crypto.pbkdf2), scrypt: promisify(crypto.scrypt) };
100
+
101
+ // password: string. params: { algo, salt(hex), ... }. → Buffer(keylen).
102
+ // SYNC: ~75 ms of the calling thread. Fine for a CLI or a test; a server on a
103
+ // request path takes `deriveKeyAsync`.
104
+ export function deriveKey(password, params) {
105
+ const [algo, args] = kdfCall(password, params);
106
+ return algo === 'pbkdf2' ? crypto.pbkdf2Sync(...args) : crypto.scryptSync(...args);
107
+ }
108
+
109
+ // The same key, derived on the libuv pool: the event loop keeps serving the
110
+ // process's other requests meanwhile.
111
+ export async function deriveKeyAsync(password, params) {
112
+ const [algo, args] = kdfCall(password, params);
113
+ return ASYNC_KDF[algo](...args);
114
+ }
115
+
116
+ // ── Login verifier ────────────────────────────────────────────────────────────
117
+ // Never the raw scrypt output: that is what a wrap key is made of, and `authKdf` is
118
+ // chosen by the server (GET /auth/prelogin) — a compromised server could serve the
119
+ // keyring's `kdf` as `authKdf` and receive the wrap key as the "verifier".
120
+ // HMAC-SHA256 keyed by scrypt(password, authKdf), over AUTH_VERIFIER_LABEL: every
121
+ // client derives the same bytes.
122
+ export const AUTH_VERIFIER_LABEL = 'sharedenv/auth-verifier/v2';
123
+
124
+ // Login params a client mints.
125
+ export function defaultAuthKdfParams() {
126
+ return { ...defaultKdfParams(), v: 2 };
127
+ }
128
+
129
+ // → the verifier a client sends for `params` as prelogin serves them. Async: scrypt
130
+ // on the libuv pool.
131
+ export async function deriveAuthVerifier(password, params) {
132
+ const stretched = await deriveKeyAsync(password, params);
133
+ return crypto.createHmac('sha256', stretched).update(AUTH_VERIFIER_LABEL).digest('hex');
134
+ }
135
+
136
+ // privateKeyHex: string. password: string. params: optional KDF params.
137
+ // → { encPrivateKey: { ciphertext, iv, authTag }, kdf: params }.
138
+ export function wrapPrivateKey(privateKeyHex, password, params = defaultKdfParams()) {
139
+ const key = deriveKey(password, params);
140
+ const encPrivateKey = gcmEncrypt(key, Buffer.from(privateKeyHex, 'utf8'));
141
+ return { encPrivateKey, kdf: params };
142
+ }
143
+
144
+ // → privateKeyHex string. Throws if the password is wrong (GCM tag fails).
145
+ export function unwrapPrivateKey(encPrivateKey, password, kdf) {
146
+ const key = deriveKey(password, kdf);
147
+ return gcmDecrypt(key, encPrivateKey).toString('utf8');
148
+ }
149
+
150
+ // A one-time recovery code shown to the user once, at keyring creation. It is a
151
+ // SECOND passphrase the private key is wrapped under (`wrapPrivateKey(pk, code)`)
152
+ // so a forgotten password no longer orphans the vault: after a password reset,
153
+ // the client unwraps with this code and re-wraps under the new password. Runs
154
+ // CLIENT-SIDE; the server never sees it. 160 bits, grouped for transcription.
155
+ export function generateRecoveryCode() {
156
+ const hex = crypto.randomBytes(20).toString('hex').toUpperCase(); // 40 chars
157
+ return hex.match(/.{5}/g).join('-'); // 8 groups of 5
158
+ }
@@ -0,0 +1,129 @@
1
+ // Keypass — secp256k1 primitives (@noble/curves): key pairs, ECDSA sign/verify, and the
2
+ // authenticated box Wallet builds on (ECDH shared secret → SHA-256 → AES-256-GCM, with a
3
+ // 65-byte signature appended to the payload). The signature proves who sealed the
4
+ // message; the GCM tag proves it was not altered.
5
+ //
6
+ // The byte format is shared by every client (Node, browser, extension):
7
+ // - private key: hex, 32 bytes. Keys minted by older clients may have dropped their
8
+ // leading zeros, so input is padded before use.
9
+ // - public key: compressed point (33 bytes), hex.
10
+ // - signed digest: SHA-256 of JSON.stringify(message). For a Buffer that is the UTF-8
11
+ // of {"type":"Buffer","data":[...]} — odd, but every sealed envelope carries it.
12
+ // - signature: r(32) || s(32) || recovery(1), deterministic (RFC 6979), low-S when
13
+ // signed here. Verification accepts both halves of s: envelopes sealed by older
14
+ // clients carry high-S signatures.
15
+ //
16
+ // The private-key operations (keyPair, sign, the ECDH of encrypt/decrypt) run CLIENT-SIDE;
17
+ // a zero-knowledge server only ever calls `verify`.
18
+
19
+ import crypto from 'crypto';
20
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
21
+
22
+ const SIG_BYTES = 65; // r(32) + s(32) + recovery(1)
23
+
24
+ // hex (possibly unpadded) → the 32 bytes of a private key.
25
+ export function privateKeyBytes(privateKeyHex) {
26
+ const hex = String(privateKeyHex);
27
+ if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) throw new Error('A private key is up to 64 hex characters');
28
+ return Buffer.from(hex.padStart(64, '0'), 'hex');
29
+ }
30
+
31
+ function digest(message) {
32
+ return crypto.createHash('sha256').update(JSON.stringify(message)).digest();
33
+ }
34
+
35
+ const hex64 = (n) => n.toString(16).padStart(64, '0');
36
+
37
+ // r or s as a bigint, a hex string or a bn.js-like value → 64 hex characters.
38
+ function scalarHex(value) {
39
+ const hex = typeof value === 'string' ? value : value.toString(16);
40
+ if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) throw new Error('Malformed signature scalar');
41
+ return hex.padStart(64, '0');
42
+ }
43
+
44
+ export class Keypass {
45
+ keyPair() {
46
+ const secretKey = secp256k1.utils.randomSecretKey();
47
+ return {
48
+ privateKey: Buffer.from(secretKey).toString('hex'),
49
+ publicKey: Buffer.from(secp256k1.getPublicKey(secretKey, true)).toString('hex'),
50
+ };
51
+ }
52
+
53
+ publicFromPrivate(privateKey) {
54
+ return Buffer.from(secp256k1.getPublicKey(privateKeyBytes(privateKey), true)).toString('hex');
55
+ }
56
+
57
+ // → { r, s, recoveryParam }, r and s as bigints.
58
+ sign(privateKey, message) {
59
+ const sig = secp256k1.sign(digest(message), privateKeyBytes(privateKey), {
60
+ prehash: false, // the digest is already SHA-256: sign it as is
61
+ format: 'recovered', // recovery(1) || r(32) || s(32)
62
+ });
63
+ const hex = Buffer.from(sig).toString('hex');
64
+ return {
65
+ r: BigInt(`0x${hex.slice(2, 66)}`),
66
+ s: BigInt(`0x${hex.slice(66, 130)}`),
67
+ recoveryParam: sig[0],
68
+ };
69
+ }
70
+
71
+ // signature: { r, s } (bigints or hex). → boolean, never throws.
72
+ verify(publicKey, message, signature) {
73
+ try {
74
+ const rs = Buffer.from(scalarHex(signature.r) + scalarHex(signature.s), 'hex');
75
+ return secp256k1.verify(rs, digest(message), Buffer.from(publicKey, 'hex'), {
76
+ prehash: false,
77
+ lowS: false,
78
+ });
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ // senderPrivateKey, recipientPublicKey: hex. packet: { signature, payload: Buffer }.
85
+ // → { encrypted, iv, authTag } as Buffers.
86
+ encrypt(senderPrivateKey, recipientPublicKey, { signature, payload }) {
87
+ const sigBuffer = Buffer.concat([
88
+ Buffer.from(hex64(signature.r) + hex64(signature.s), 'hex'),
89
+ Buffer.from([signature.recoveryParam || 0]),
90
+ ]);
91
+ const messageBuffer = Buffer.concat([payload, sigBuffer]);
92
+
93
+ const encryptionKey = this.#sharedKey(senderPrivateKey, recipientPublicKey);
94
+ const iv = crypto.randomBytes(12);
95
+ const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
96
+ const encrypted = Buffer.concat([cipher.update(messageBuffer), cipher.final()]);
97
+
98
+ return { encrypted, iv, authTag: cipher.getAuthTag() };
99
+ }
100
+
101
+ // recipientPrivateKey, senderPublicKey: hex. encryptedData: { encrypted, iv, authTag } Buffers.
102
+ // → { payload: Buffer, signature } — throws if the GCM tag fails (altered or wrong key).
103
+ decrypt(recipientPrivateKey, senderPublicKey, { encrypted, iv, authTag }) {
104
+ const decryptionKey = this.#sharedKey(recipientPrivateKey, senderPublicKey);
105
+ const decipher = crypto.createDecipheriv('aes-256-gcm', decryptionKey, iv);
106
+ decipher.setAuthTag(authTag);
107
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
108
+
109
+ const payload = decrypted.subarray(0, decrypted.length - SIG_BYTES);
110
+ const sigHex = decrypted.subarray(decrypted.length - SIG_BYTES).toString('hex');
111
+ const signature = {
112
+ r: BigInt(`0x${sigHex.slice(0, 64)}`),
113
+ s: BigInt(`0x${sigHex.slice(64, 128)}`),
114
+ recoveryParam: parseInt(sigHex.slice(128, 130), 16),
115
+ };
116
+
117
+ return { payload, signature };
118
+ }
119
+
120
+ // ECDH shared secret (its X coordinate) → SHA-256 → 32-byte symmetric key.
121
+ #sharedKey(privateKey, publicKey) {
122
+ const point = secp256k1.getSharedSecret(
123
+ privateKeyBytes(privateKey),
124
+ Buffer.from(publicKey, 'hex'),
125
+ true
126
+ );
127
+ return crypto.createHash('sha256').update(point.subarray(1)).digest();
128
+ }
129
+ }
@@ -0,0 +1,49 @@
1
+ // Secret blobs of the zero-knowledge vault — every client (Node, browser) writes and reads
2
+ // the same bytes.
3
+ //
4
+ // { v: 2, ciphertext, iv, authTag, ctx } — ctx = { safeId, key, kind } is the secret's
5
+ // place, authenticated as AES-GCM additional data. A reader that knows where it found the
6
+ // blob compares and refuses a stranger; without that, the blob is read at the place it
7
+ // names. Any other shape is refused: a ciphertext with no place would be valid wherever a
8
+ // server put it (two values of a safe swapped, a sealed meta served as a value).
9
+
10
+ import { gcmDecrypt, gcmEncrypt } from './aes.js';
11
+
12
+ const LABELS = { value: 'sharedenv/secret/v2', meta: 'sharedenv/meta/v2' };
13
+
14
+ // → the canonical ctx. Throws on anything but { safeId, key, kind? }.
15
+ function placeOf(ctx) {
16
+ const kind = ctx?.kind || 'value';
17
+ if (!LABELS[kind] || ctx?.safeId == null || ctx?.key == null) {
18
+ throw new Error('A secret context is { safeId, key, kind: value|meta }');
19
+ }
20
+ return { safeId: String(ctx.safeId), key: String(ctx.key), kind };
21
+ }
22
+
23
+ const aadOf = (place) =>
24
+ Buffer.from([LABELS[place.kind], place.safeId, place.key].join('\u0000'), 'utf8');
25
+
26
+ // dekHex, plaintext: strings. ctx: the place the blob is bound to. → a v2 blob.
27
+ export function sealSecret(dekHex, plaintext, ctx) {
28
+ const place = placeOf(ctx);
29
+ const blob = gcmEncrypt(
30
+ Buffer.from(dekHex, 'hex'),
31
+ Buffer.from(String(plaintext), 'utf8'),
32
+ aadOf(place)
33
+ );
34
+ return { v: 2, ...blob, ctx: place };
35
+ }
36
+
37
+ // → plaintext. `expected` (optional): where the caller found the blob — a blob from
38
+ // anywhere else is refused. Throws when no key opens it (AES-GCM authenticates).
39
+ export function openSecret(dekHex, blob, expected) {
40
+ if (blob?.v !== 2) throw new Error('Not a secret blob: { v: 2, ..., ctx } expected');
41
+ const place = placeOf(blob.ctx);
42
+ if (expected) {
43
+ const want = placeOf(expected);
44
+ if (want.safeId !== place.safeId || want.key !== place.key || want.kind !== place.kind) {
45
+ throw new Error(`This blob belongs to ${place.kind} "${place.key}" of safe ${place.safeId}`);
46
+ }
47
+ }
48
+ return gcmDecrypt(Buffer.from(dekHex, 'hex'), blob, aadOf(place)).toString('utf8');
49
+ }
@@ -0,0 +1,63 @@
1
+ // Wallet — the seal/open layer on top of Keypass. `seal`/`open` exchange a
2
+ // self-contained, JSON-serializable hex envelope { ciphertext, iv, authTag,
3
+ // senderPublicKey } that drops straight into opaque `json` Vault fields. `open`
4
+ // returns `false` (never throws) on any tamper or wrong key.
5
+
6
+ import { Keypass } from './keypass.js';
7
+
8
+ export class Wallet {
9
+ constructor() {
10
+ this.keypass = new Keypass();
11
+ }
12
+
13
+ create() {
14
+ return this.keypass.keyPair();
15
+ }
16
+
17
+ // senderPrivateKey, recipientPublicKey: hex. payload: Buffer.
18
+ // → { ciphertext, iv, authTag, senderPublicKey } as hex strings.
19
+ seal(senderPrivateKey, recipientPublicKey, payload) {
20
+ const signature = this.keypass.sign(senderPrivateKey, payload);
21
+ const { encrypted, iv, authTag } = this.keypass.encrypt(senderPrivateKey, recipientPublicKey, {
22
+ payload,
23
+ signature,
24
+ });
25
+ return {
26
+ ciphertext: encrypted.toString('hex'),
27
+ iv: iv.toString('hex'),
28
+ authTag: authTag.toString('hex'),
29
+ senderPublicKey: this.keypass.publicFromPrivate(senderPrivateKey),
30
+ };
31
+ }
32
+
33
+ // recipientPrivateKey: hex. envelope: { ciphertext, iv, authTag, senderPublicKey }.
34
+ // → payload Buffer, or `false` if decryption or signature verification fails.
35
+ //
36
+ // The signature only proves the envelope was sealed by the key it NAMES itself, and
37
+ // any key can seal a payload of its choosing to ours. `from` (a public key, or a
38
+ // list) is the sender the caller expects: an envelope from anyone else is refused
39
+ // like a tampered one. Every client implements the same contract.
40
+ open(recipientPrivateKey, envelope, { from } = {}) {
41
+ const { ciphertext, iv, authTag, senderPublicKey } = envelope;
42
+ if (from) {
43
+ const expected = (Array.isArray(from) ? from : [from]).map((k) => String(k).toLowerCase());
44
+ if (!expected.includes(String(senderPublicKey).toLowerCase())) return false;
45
+ }
46
+ let decrypted;
47
+ try {
48
+ decrypted = this.keypass.decrypt(recipientPrivateKey, senderPublicKey, {
49
+ encrypted: Buffer.from(ciphertext, 'hex'),
50
+ iv: Buffer.from(iv, 'hex'),
51
+ authTag: Buffer.from(authTag, 'hex'),
52
+ });
53
+ } catch {
54
+ return false; // GCM tag failed → ciphertext or key tampered
55
+ }
56
+
57
+ const { payload, signature } = decrypted;
58
+ if (!this.keypass.verify(senderPublicKey, payload, signature)) {
59
+ return false; // signature does not match the claimed sender
60
+ }
61
+ return payload;
62
+ }
63
+ }
package/lib/crypto.js ADDED
@@ -0,0 +1,10 @@
1
+ // The crypto the CLI uses: copies of the framework files, under ./crypto.
2
+
3
+ export { Wallet } from './crypto/wallet.js';
4
+ export {
5
+ assertKdfParams,
6
+ deriveAuthVerifier,
7
+ unwrapPrivateKey,
8
+ } from './crypto/kdf.js';
9
+ export { openSecret } from './crypto/secretBlob.js';
10
+ export { fingerprintOf } from './crypto/fingerprint.js';
package/lib/env.js ADDED
@@ -0,0 +1,96 @@
1
+ // The environment `sharedenv run` gives the child process.
2
+ //
3
+ // A safe is writable by every WRITER on it. Injected blindly, its values would make write
4
+ // access to a shared safe code execution — or eavesdropping — on the machine of whoever
5
+ // runs it next (a teammate's laptop, the CI runner). Two rules:
6
+ //
7
+ // - names that make the dynamic loader, a shell, an interpreter or a tool the child
8
+ // spawns run code of someone else's choosing, or route and trust its traffic
9
+ // (UNSAFE_ENV), are held back unless `--allow-unsafe-env`;
10
+ // - no such list is complete, so a name the caller's environment already sets is not
11
+ // overridden either, unless `--override-env`.
12
+ //
13
+ // And the child never receives the CLI's own SHAREDENV_* variables: SHAREDENV_PASSWORD
14
+ // opens every safe of the account, not just the one being run.
15
+
16
+ const UNSAFE_ENV = [
17
+ // dynamic loader, shells
18
+ /^LD_/,
19
+ /^DYLD_/,
20
+ /^PATH$/,
21
+ /^IFS$/,
22
+ /^BASH_ENV$/,
23
+ /^BASH_FUNC_/,
24
+ /^ENV$/,
25
+ /^SHELLOPTS$/,
26
+ /^BASHOPTS$/,
27
+ /^PS4$/,
28
+ /^PROMPT_COMMAND$/,
29
+ /^ZDOTDIR$/,
30
+ // interpreters
31
+ /^NODE_OPTIONS$/,
32
+ /^NODE_PATH$/,
33
+ /^NODE_REPL_EXTERNAL_MODULE$/,
34
+ /^PYTHONSTARTUP$/,
35
+ /^PYTHONPATH$/,
36
+ /^PYTHONHOME$/,
37
+ /^PYTHONINSPECT$/,
38
+ /^PERL5OPT$/,
39
+ /^PERL5LIB$/,
40
+ /^PERLLIB$/,
41
+ /^RUBYOPT$/,
42
+ /^RUBYLIB$/,
43
+ /^JAVA_TOOL_OPTIONS$/,
44
+ /^_JAVA_OPTIONS$/,
45
+ /^JDK_JAVA_OPTIONS$/,
46
+ // tools the child may spawn
47
+ /^GIT_/,
48
+ /^NPM_CONFIG_/,
49
+ /^YARN_/,
50
+ /^COMPOSER_/,
51
+ /^PAGER$/,
52
+ /^MANPAGER$/,
53
+ /^LESSOPEN$/,
54
+ /^LESSCLOSE$/,
55
+ /^EDITOR$/,
56
+ /^VISUAL$/,
57
+ /^BROWSER$/,
58
+ /^SSH_ASKPASS$/,
59
+ /^SUDO_ASKPASS$/,
60
+ // where the child's traffic goes, and which certificates it trusts: together they let
61
+ // a writer read the other secrets in flight
62
+ /^(HTTPS?|ALL|FTP)_PROXY$/,
63
+ /^NODE_EXTRA_CA_CERTS$/,
64
+ /^NODE_TLS_REJECT_UNAUTHORIZED$/,
65
+ /^SSL_CERT_(FILE|DIR)$/,
66
+ /_CA_BUNDLE$/,
67
+ // this CLI's own configuration (a nested `sharedenv` would talk to another server)
68
+ /^SHAREDENV_/,
69
+ ];
70
+
71
+ const OWN_ENV = /^SHAREDENV_/i;
72
+
73
+ // → { safe: {…}, blocked: [names], kept: [names] }. Matched case-insensitively.
74
+ // blocked — UNSAFE_ENV names, unless `allowUnsafe`;
75
+ // kept — names `base` (the caller's environment) already sets: its value stays,
76
+ // unless `override`.
77
+ export function splitUnsafeEnv(map, { base = {}, allowUnsafe = false, override = false } = {}) {
78
+ const safe = {};
79
+ const blocked = [];
80
+ const kept = [];
81
+ for (const [name, value] of Object.entries(map)) {
82
+ if (!allowUnsafe && UNSAFE_ENV.some((re) => re.test(name.toUpperCase()))) blocked.push(name);
83
+ else if (!override && Object.hasOwn(base, name)) kept.push(name);
84
+ else safe[name] = value;
85
+ }
86
+ return { safe, blocked, kept };
87
+ }
88
+
89
+ // The child's environment: the caller's, minus SHAREDENV_*, plus the injected secrets.
90
+ export function childEnv(parentEnv, injected) {
91
+ const env = {};
92
+ for (const [name, value] of Object.entries(parentEnv)) {
93
+ if (!OWN_ENV.test(name)) env[name] = value;
94
+ }
95
+ return { ...env, ...injected };
96
+ }
package/lib/format.js ADDED
@@ -0,0 +1,42 @@
1
+ // What `sharedenv pull` writes. Keys are sorted, so two pulls of the same safe give the
2
+ // same file.
3
+ //
4
+ // A .env is read by dotenv-style parsers AND sourced by shells (`set -a; . ./.env`).
5
+ // Each value is written in the first form both read literally — nothing expanded,
6
+ // nothing executed: a value is chosen by any WRITER on the safe, and `$(…)` in double
7
+ // quotes would run on the machine that sources the file.
8
+ //
9
+ // bare only characters with no meaning to either
10
+ // '…' anything without a single quote (newlines included)
11
+ // "…" a single quote, but none of " $ ` \ (which a shell or dotenv would interpret)
12
+ //
13
+ // A value that fits none (or holds a carriage return, which dotenv rewrites) is refused
14
+ // with its name: `--format json` or `sharedenv run` carry it as is.
15
+
16
+ const BARE = /^[A-Za-z0-9_\-.,:/@+%]+$/;
17
+
18
+ const sorted = (map) => Object.entries(map).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
19
+
20
+ function dotenvValue(name, value) {
21
+ if (BARE.test(value)) return value;
22
+ if (!value.includes('\r')) {
23
+ if (!value.includes("'")) return `'${value}'`;
24
+ if (!/["$`\\]/.test(value)) return `"${value}"`;
25
+ }
26
+ throw new Error(
27
+ `"${name}" cannot be written as a .env line that is safe to source (it holds a ' with ` +
28
+ 'one of " $ ` \\, or a carriage return): use --format json, or sharedenv run'
29
+ );
30
+ }
31
+
32
+ export function toDotenv(map) {
33
+ return sorted(map)
34
+ .map(([name, value]) => `${name}=${dotenvValue(name, value)}`)
35
+ .join('\n');
36
+ }
37
+
38
+ export function toJson(map) {
39
+ return JSON.stringify(Object.fromEntries(sorted(map)), null, 2);
40
+ }
41
+
42
+ export const FORMATS = { dotenv: toDotenv, json: toJson };