mossring 1.0.1 → 2.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/README.md +27 -17
- package/bin/cli.js +65 -44
- package/package.json +2 -2
- package/src/crypto.js +75 -28
- package/src/recipient.js +3 -0
package/README.md
CHANGED
|
@@ -1,50 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mossring
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
非对称加密本地文件的命令行工具:**X25519 + AES-256-GCM**(密封盒 / sealed box)。零第三方依赖,仅用 Node.js 内置 `crypto`。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **加密**用内置公钥,**无需指定任何密钥**,方便。
|
|
6
|
+
- **解密**必须用你本地保存的**私钥**,安全。
|
|
7
|
+
- 公钥可以公开(包里就内置了一个);只有持有私钥的人能解密。
|
|
8
|
+
|
|
9
|
+
> 隐私靠真正的加密,而非伪装。没有私钥,文件内容就是无法读取的密文。
|
|
6
10
|
|
|
7
11
|
## 安装
|
|
8
12
|
|
|
9
13
|
```bash
|
|
10
14
|
npm install -g mossring
|
|
11
15
|
# 或免安装直接用:
|
|
12
|
-
npx mossring
|
|
16
|
+
npx mossring help
|
|
13
17
|
```
|
|
14
18
|
|
|
15
19
|
## 使用
|
|
16
20
|
|
|
17
21
|
```bash
|
|
18
|
-
#
|
|
19
|
-
mossring
|
|
22
|
+
# 用内置公钥加密 → 生成 secret.pdf.enc(不需要任何密钥)
|
|
23
|
+
mossring encrypt secret.pdf
|
|
24
|
+
|
|
25
|
+
# 用你的私钥解密
|
|
26
|
+
mossring decrypt secret.pdf.enc -k mossring.priv
|
|
27
|
+
```
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
mossring encrypt secret.pdf -k my.key
|
|
29
|
+
### 自己生成密钥对
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
mossring
|
|
31
|
+
```bash
|
|
32
|
+
mossring keygen -o mykey # 生成 mykey.priv(私钥)和 mykey.pub(公钥)
|
|
33
|
+
mossring encrypt data.zip -p mykey.pub # 用指定公钥加密
|
|
34
|
+
mossring decrypt data.zip.enc -k mykey.priv
|
|
26
35
|
```
|
|
27
36
|
|
|
28
37
|
### 选项
|
|
29
38
|
|
|
30
39
|
| 选项 | 说明 |
|
|
31
40
|
| --- | --- |
|
|
32
|
-
| `-
|
|
41
|
+
| `-p, --pub` | 接收方公钥文件(加密时不指定则用内置公钥) |
|
|
42
|
+
| `-k, --key` | 私钥文件(解密时必需) |
|
|
33
43
|
| `-o, --out` | 输出文件路径 |
|
|
34
44
|
| `-f, --force` | 覆盖已存在的输出文件 |
|
|
35
45
|
| `-h, --help` | 显示帮助 |
|
|
36
46
|
|
|
37
47
|
## 加密细节
|
|
38
48
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
49
|
+
- **密钥协商**:X25519 ECDH,每次加密用一次性临时密钥对(前向保密)
|
|
50
|
+
- **对称加密**:AES-256-GCM(带完整性校验,篡改会在解密时报错)
|
|
51
|
+
- **密钥派生**:HKDF-SHA256,salt 绑定临时公钥与接收方公钥
|
|
52
|
+
- **文件格式**:`MAGIC(5) | VERSION(1) | EPK_LEN(2) | 临时公钥 | IV(12) | TAG(16) | 密文`
|
|
43
53
|
|
|
44
54
|
## 重要提示
|
|
45
55
|
|
|
46
|
-
-
|
|
47
|
-
-
|
|
56
|
+
- **私钥丢失 = 数据永久无法恢复**,请离线备份私钥文件。
|
|
57
|
+
- 公钥可公开;私钥绝不要提交到仓库或发布到 npm。
|
|
48
58
|
- 当前实现会将整个文件读入内存,超大文件请注意内存占用。
|
|
49
59
|
|
|
50
60
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
3
3
|
import { argv, exit } from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import { generateKeyPair, encrypt, decrypt } from "../src/crypto.js";
|
|
5
|
+
import { BUILTIN_PUBLIC_KEY } from "../src/recipient.js";
|
|
5
6
|
|
|
6
|
-
const HELP = `mossring —
|
|
7
|
+
const HELP = `mossring — 非对称加密本地文件 (X25519 + AES-256-GCM)
|
|
8
|
+
|
|
9
|
+
加密用内置公钥,无需指定密钥;解密用你本地保存的私钥。
|
|
7
10
|
|
|
8
11
|
用法:
|
|
9
|
-
mossring keygen [-o
|
|
10
|
-
mossring encrypt <文件> -
|
|
11
|
-
mossring decrypt <文件> -k
|
|
12
|
+
mossring keygen [-o <前缀>] [-f] 生成密钥对(<前缀>.priv / <前缀>.pub)
|
|
13
|
+
mossring encrypt <文件> [-p <公钥文件>] [-o <输出>] [-f]
|
|
14
|
+
mossring decrypt <文件> -k <私钥文件> [-o <输出>] [-f]
|
|
12
15
|
|
|
13
16
|
选项:
|
|
14
|
-
-
|
|
17
|
+
-p, --pub 指定接收方公钥文件(不指定则用内置公钥)
|
|
18
|
+
-k, --key 私钥文件路径(解密用)
|
|
15
19
|
-o, --out 输出文件路径
|
|
16
20
|
-f, --force 覆盖已存在的输出文件
|
|
17
21
|
-h, --help 显示帮助
|
|
18
22
|
|
|
19
23
|
示例:
|
|
20
|
-
mossring
|
|
21
|
-
mossring
|
|
22
|
-
mossring decrypt secret.pdf.enc -k my.key # 还原 secret.pdf
|
|
24
|
+
mossring encrypt secret.pdf # 用内置公钥加密 → secret.pdf.enc
|
|
25
|
+
mossring decrypt secret.pdf.enc -k mossring.priv
|
|
23
26
|
|
|
24
|
-
注意:
|
|
27
|
+
注意: 公钥可公开,私钥务必离线保存。私钥丢失 = 加密文件无法恢复。`;
|
|
25
28
|
|
|
26
29
|
function fail(msg) {
|
|
27
30
|
console.error("错误:" + msg);
|
|
@@ -34,6 +37,7 @@ function parseArgs(args) {
|
|
|
34
37
|
for (let i = 0; i < args.length; i++) {
|
|
35
38
|
const a = args[i];
|
|
36
39
|
if (a === "-k" || a === "--key") opts.key = args[++i];
|
|
40
|
+
else if (a === "-p" || a === "--pub") opts.pub = args[++i];
|
|
37
41
|
else if (a === "-o" || a === "--out") opts.out = args[++i];
|
|
38
42
|
else if (a === "-f" || a === "--force") opts.force = true;
|
|
39
43
|
else if (a === "-h" || a === "--help") opts.help = true;
|
|
@@ -48,14 +52,9 @@ function ensureWritable(path, force) {
|
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
function
|
|
52
|
-
if (!
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
return decodeKey(readFileSync(keyPath, "utf8"));
|
|
56
|
-
} catch (e) {
|
|
57
|
-
return fail(e.message);
|
|
58
|
-
}
|
|
55
|
+
function readKeyFile(path, what) {
|
|
56
|
+
if (!existsSync(path)) fail(`${what}不存在:${path}`);
|
|
57
|
+
return readFileSync(path, "utf8").trim();
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
const [, , cmd, ...rest] = argv;
|
|
@@ -73,44 +72,66 @@ if (opts.help) {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
if (cmd === "keygen") {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const prefix = opts.out || "mossring";
|
|
76
|
+
const privPath = `${prefix}.priv`;
|
|
77
|
+
const pubPath = `${prefix}.pub`;
|
|
78
|
+
ensureWritable(privPath, opts.force);
|
|
79
|
+
ensureWritable(pubPath, opts.force);
|
|
80
|
+
const { publicKeyB64, privateKeyB64 } = generateKeyPair();
|
|
81
|
+
writeFileSync(privPath, privateKeyB64 + "\n", { mode: 0o600 });
|
|
79
82
|
try {
|
|
80
|
-
chmodSync(
|
|
83
|
+
chmodSync(privPath, 0o600);
|
|
81
84
|
} catch {
|
|
82
|
-
/*
|
|
85
|
+
/* 非 POSIX 平台忽略 */
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
console.log(
|
|
87
|
+
writeFileSync(pubPath, publicKeyB64 + "\n");
|
|
88
|
+
console.log(`已生成密钥对:`);
|
|
89
|
+
console.log(` 私钥 → ${privPath}(请离线保存,切勿公开)`);
|
|
90
|
+
console.log(` 公钥 → ${pubPath}(可公开)`);
|
|
91
|
+
console.log("⚠️ 私钥丢失 = 加密文件永久无法恢复。");
|
|
86
92
|
exit(0);
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
if (cmd === "encrypt"
|
|
95
|
+
if (cmd === "encrypt") {
|
|
90
96
|
const input = positional[0];
|
|
91
97
|
if (!input) fail("缺少输入文件");
|
|
92
98
|
if (!existsSync(input)) fail(`输入文件不存在:${input}`);
|
|
93
|
-
const
|
|
99
|
+
const pub = opts.pub ? readKeyFile(opts.pub, "公钥文件") : BUILTIN_PUBLIC_KEY;
|
|
100
|
+
if (!pub) {
|
|
101
|
+
fail("没有可用的公钥:包内未内置公钥,请用 -p <公钥文件> 指定,或先运行 mossring keygen");
|
|
102
|
+
}
|
|
103
|
+
const out = opts.out || input + ".enc";
|
|
104
|
+
ensureWritable(out, opts.force);
|
|
94
105
|
const data = readFileSync(input);
|
|
106
|
+
let blob;
|
|
107
|
+
try {
|
|
108
|
+
blob = encrypt(data, pub);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
fail(e.message);
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(out, blob);
|
|
113
|
+
console.log(`已加密:${input} → ${out}`);
|
|
114
|
+
exit(0);
|
|
115
|
+
}
|
|
95
116
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
writeFileSync(out, plain);
|
|
112
|
-
console.log(`已解密:${input} → ${out}`);
|
|
117
|
+
if (cmd === "decrypt") {
|
|
118
|
+
const input = positional[0];
|
|
119
|
+
if (!input) fail("缺少输入文件");
|
|
120
|
+
if (!existsSync(input)) fail(`输入文件不存在:${input}`);
|
|
121
|
+
if (!opts.key) fail("缺少私钥文件,请用 -k <私钥文件> 指定");
|
|
122
|
+
const priv = readKeyFile(opts.key, "私钥文件");
|
|
123
|
+
const out =
|
|
124
|
+
opts.out || (input.endsWith(".enc") ? input.slice(0, -4) : input + ".dec");
|
|
125
|
+
ensureWritable(out, opts.force);
|
|
126
|
+
const data = readFileSync(input);
|
|
127
|
+
let plain;
|
|
128
|
+
try {
|
|
129
|
+
plain = decrypt(data, priv);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
fail(e.message);
|
|
113
132
|
}
|
|
133
|
+
writeFileSync(out, plain);
|
|
134
|
+
console.log(`已解密:${input} → ${out}`);
|
|
114
135
|
exit(0);
|
|
115
136
|
}
|
|
116
137
|
|
package/package.json
CHANGED
package/src/crypto.js
CHANGED
|
@@ -1,47 +1,85 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createPublicKey,
|
|
5
|
+
createPrivateKey,
|
|
6
|
+
generateKeyPairSync,
|
|
7
|
+
diffieHellman,
|
|
8
|
+
randomBytes,
|
|
9
|
+
hkdfSync,
|
|
10
|
+
} from "node:crypto";
|
|
2
11
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
const
|
|
12
|
+
// 非对称「密封盒」格式 v2(X25519 + AES-256-GCM):
|
|
13
|
+
// MAGIC(5) | VERSION(1) | EPK_LEN(2) | 临时公钥(DER) | IV(12) | TAG(16) | 密文
|
|
14
|
+
const MAGIC = Buffer.from("ANON2", "utf8");
|
|
15
|
+
const VERSION = 2;
|
|
6
16
|
const KEY_LEN = 32; // AES-256
|
|
7
|
-
const
|
|
8
|
-
const IV_LEN = 12; // GCM 推荐 96-bit
|
|
17
|
+
const IV_LEN = 12;
|
|
9
18
|
const TAG_LEN = 16;
|
|
10
|
-
const HKDF_INFO = Buffer.from("
|
|
19
|
+
const HKDF_INFO = Buffer.from("mossring/sealed-box/v2", "utf8");
|
|
11
20
|
|
|
12
|
-
//
|
|
13
|
-
export function
|
|
14
|
-
|
|
21
|
+
// 生成一对 X25519 密钥;公钥可公开内置,私钥务必本地保存
|
|
22
|
+
export function generateKeyPair() {
|
|
23
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
24
|
+
return {
|
|
25
|
+
publicKeyB64: publicKey.export({ type: "spki", format: "der" }).toString("base64"),
|
|
26
|
+
privateKeyB64: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
|
|
27
|
+
};
|
|
15
28
|
}
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
function importPublicKey(b64) {
|
|
31
|
+
try {
|
|
32
|
+
return createPublicKey({
|
|
33
|
+
key: Buffer.from(String(b64).trim(), "base64"),
|
|
34
|
+
format: "der",
|
|
35
|
+
type: "spki",
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("无效的公钥");
|
|
39
|
+
}
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
function importPrivateKey(b64) {
|
|
43
|
+
try {
|
|
44
|
+
return createPrivateKey({
|
|
45
|
+
key: Buffer.from(String(b64).trim(), "base64"),
|
|
46
|
+
format: "der",
|
|
47
|
+
type: "pkcs8",
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error("无效的私钥文件");
|
|
25
51
|
}
|
|
26
|
-
return buf;
|
|
27
52
|
}
|
|
28
53
|
|
|
29
|
-
//
|
|
30
|
-
function deriveKey(
|
|
31
|
-
|
|
54
|
+
// 把双方公钥并入 HKDF 的 salt,将密钥绑定到本次收发上下文
|
|
55
|
+
function deriveKey(sharedSecret, ephemeralPubDer, recipientPubDer) {
|
|
56
|
+
const salt = Buffer.concat([ephemeralPubDer, recipientPubDer]);
|
|
57
|
+
return Buffer.from(hkdfSync("sha256", sharedSecret, salt, HKDF_INFO, KEY_LEN));
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
export function encrypt(plaintext,
|
|
35
|
-
const
|
|
60
|
+
export function encrypt(plaintext, recipientPubB64) {
|
|
61
|
+
const recipientPub = importPublicKey(recipientPubB64);
|
|
62
|
+
const recipientPubDer = recipientPub.export({ type: "spki", format: "der" });
|
|
63
|
+
|
|
64
|
+
// 每次加密用一把一次性临时密钥对,保证前向保密
|
|
65
|
+
const eph = generateKeyPairSync("x25519");
|
|
66
|
+
const ephPubDer = eph.publicKey.export({ type: "spki", format: "der" });
|
|
67
|
+
|
|
68
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipientPub });
|
|
69
|
+
const key = deriveKey(shared, ephPubDer, recipientPubDer);
|
|
70
|
+
|
|
36
71
|
const iv = randomBytes(IV_LEN);
|
|
37
|
-
const key = deriveKey(masterKey, salt);
|
|
38
72
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
39
73
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
40
74
|
const tag = cipher.getAuthTag();
|
|
41
|
-
|
|
75
|
+
|
|
76
|
+
const epkLen = Buffer.alloc(2);
|
|
77
|
+
epkLen.writeUInt16BE(ephPubDer.length);
|
|
78
|
+
|
|
79
|
+
return Buffer.concat([MAGIC, Buffer.from([VERSION]), epkLen, ephPubDer, iv, tag, ciphertext]);
|
|
42
80
|
}
|
|
43
81
|
|
|
44
|
-
export function decrypt(blob,
|
|
82
|
+
export function decrypt(blob, privateKeyB64) {
|
|
45
83
|
let offset = 0;
|
|
46
84
|
const magic = blob.subarray(offset, (offset += MAGIC.length));
|
|
47
85
|
if (!magic.equals(MAGIC)) {
|
|
@@ -52,16 +90,25 @@ export function decrypt(blob, masterKey) {
|
|
|
52
90
|
if (version !== VERSION) {
|
|
53
91
|
throw new Error(`不支持的文件版本:${version}`);
|
|
54
92
|
}
|
|
55
|
-
const
|
|
93
|
+
const epkLen = blob.readUInt16BE(offset);
|
|
94
|
+
offset += 2;
|
|
95
|
+
const ephPubDer = Buffer.from(blob.subarray(offset, (offset += epkLen)));
|
|
56
96
|
const iv = blob.subarray(offset, (offset += IV_LEN));
|
|
57
97
|
const tag = blob.subarray(offset, (offset += TAG_LEN));
|
|
58
98
|
const ciphertext = blob.subarray(offset);
|
|
59
|
-
|
|
99
|
+
|
|
100
|
+
const priv = importPrivateKey(privateKeyB64);
|
|
101
|
+
const recipientPubDer = createPublicKey(priv).export({ type: "spki", format: "der" });
|
|
102
|
+
const ephPub = createPublicKey({ key: ephPubDer, format: "der", type: "spki" });
|
|
103
|
+
|
|
104
|
+
const shared = diffieHellman({ privateKey: priv, publicKey: ephPub });
|
|
105
|
+
const key = deriveKey(shared, ephPubDer, recipientPubDer);
|
|
106
|
+
|
|
60
107
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
61
108
|
decipher.setAuthTag(tag);
|
|
62
109
|
try {
|
|
63
110
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
64
111
|
} catch {
|
|
65
|
-
throw new Error("
|
|
112
|
+
throw new Error("解密失败:私钥不匹配,或文件已损坏/被篡改");
|
|
66
113
|
}
|
|
67
114
|
}
|
package/src/recipient.js
ADDED