diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17c6a56..2df7d7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,23 @@ jobs: name: Test runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - otp: ['27'] - elixir: ['1.18'] + include: + # Minimum versions declared in README.md and mix.exs. + - os: ubuntu-latest + otp: '24.3' + elixir: '1.14.5' + # Current primary toolchain on every supported runner. + - os: ubuntu-latest + otp: '27' + elixir: '1.18' + - os: windows-latest + otp: '27' + elixir: '1.18' + - os: macos-latest + otp: '27' + elixir: '1.18' steps: - name: Preserve LF line endings @@ -39,9 +52,9 @@ jobs: path: | deps _build - key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} + key: ${{ runner.os }}-otp-${{ matrix.otp }}-elixir-${{ matrix.elixir }}-mix-${{ hashFiles('mix.lock') }} restore-keys: | - ${{ runner.os }}-mix- + ${{ runner.os }}-otp-${{ matrix.otp }}-elixir-${{ matrix.elixir }}-mix- - name: Install Hex and Rebar run: | @@ -52,6 +65,7 @@ jobs: run: mix deps.get - name: Check formatting + if: matrix.os == 'ubuntu-latest' && matrix.elixir == '1.18' run: mix format --check-formatted - name: Compile with warnings as errors diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f5b90e..15467aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,17 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + + - name: Verify tag matches package version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + mix_version="$(sed -n 's/.*@version "\([^"]*\)".*/\1/p' mix.exs)" + + if [ -z "$mix_version" ] || [ "$tag_version" != "$mix_version" ]; then + echo "Release tag version '$tag_version' does not match mix.exs version '$mix_version'." >&2 + exit 1 + fi - name: Set up Erlang/OTP and Elixir uses: erlef/setup-beam@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7c140..6617009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,27 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Added - Added `bench/bench.exs` micro-benchmark for the pure-Elixir SM2, SM3, and SM4 cores. +- Benchmark output now records runtime/OS/architecture parameters and reports + min/median/max across repeated samples. - Added large-input SM4 CTR coverage and fixed SM2 scalar-multiplication vectors. +- Added explicit standard SM2 APIs for ZA-aware raw signatures and C1/C3/C2 + encryption using the counter-based SM3 KDF; legacy APIs retain their format. ### Changed +- CI now verifies the documented minimum Elixir 1.14/OTP 24 toolchain in addition + to the current cross-platform toolchain. +- Release jobs now reject tags whose version does not match `mix.exs`. +- Removed the reserved, behaviorless SM2 CLI `--hex` option; SM2 keys, + signatures, and ciphertext remain hex-encoded where documented. +- Added public API documentation for SM2, SM3, and SM4, and report malformed + SM2 message iodata as `:invalid_input` instead of `:invalid_key`. +- CLI positional arguments now consistently mean message text; file input uses + the explicit `--file ` option for SM2, SM3, and SM4. +- Added SM4-CTR CLI encryption/decryption with an explicit required 16-byte + `--counter`; padding options are rejected in CTR mode. +- Added doctests for the documented facade and SM3 examples; CLI examples remain + covered by subprocess integration tests. - Optimized SM3 compression with a rolling 16-word message schedule, precomputed round constants, and specialized rotations. - Optimized SM2 scalar multiplication with Jacobian coordinates and mixed @@ -25,6 +42,12 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Documentation +- Added an implementation-oriented SM2 standards and migration design covering + ZA/user IDs, signature encoding, KDF, ciphertext layout, and legacy handling. +- Added a security policy covering SM2 migration, SM4 integrity composition, + key/randomness handling, side-channel limits, and unsupported use cases. +- Documented reproducible benchmark comparison rules and scoped future streaming, + certificate parsing, and SM9 work with explicit implementation prerequisites. - Corrected the README SM3 CLI example and removed unverified performance claims. - Clarified API return values, binary formats, SM2 interoperability limits, and SM2 long-message confidentiality limits and SM4 confidentiality-only mode caveats. diff --git a/README.md b/README.md index 4864b06..7fa3e7f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,15 @@ 国密算法纯 Elixir 实现(GM/T 0002-2012, GM/T 0003-2012, GM/T 0004-2012),无需外部依赖。 -> 当前 SM2 签名与加密接口有明确的兼容性限制,不应直接视为完整的跨实现标准 SM2 接口;其中加密接口不得用于保护敏感数据。详见[兼容性与安全边界](#兼容性与安全边界)。 +> SM2 标准接口已有公开向量和 OpenSSL 互操作测试,但尚未完成独立安全审查;旧兼容加密接口不得用于保护敏感数据。详见[兼容性与安全边界](#兼容性与安全边界)。 ## 文档导航 - [库 API 快速入门](#使用) - [命令行工具完整说明](cli.md) +- [SM2 标准兼容与迁移设计](sm2_migration.md) +- [安全策略与适用边界](SECURITY.md) +- [流式 API、证书与 SM9 范围](future_work.md) - [版本变更记录](CHANGELOG.md) - [开发路线图](todo.md) - [发布维护指南](hex.pm.md) @@ -39,8 +42,8 @@ | SM3 | 纯 Elixir 实现(GM/T 0004-2012 压缩函数),32 字节摘要 | | SM4 ECB/CBC | 纯 Elixir 实现(GM/T 0002-2012 S-box 与密钥扩展),支持 `:pkcs7` 与 `:none` 填充 | | SM4 CTR | 纯 Elixir 实现,16 字节初始计数器块按大端 128 位整数递增,不提供认证 | -| SM2 密钥/签名 | 纯 Elixir 椭圆曲线运算,SM3 预哈希,64 字节 raw `r || s` 签名 | -| SM2 加密/解密 | Guomi 内部 `C1 || C2 || C3` 格式,仅保留用于兼容和测试;不得用于敏感数据 | +| SM2 密钥/签名 | 标准接口计算 ZA,签名为 64 字节 raw `r || s`;另保留不计算 ZA 的旧兼容接口 | +| SM2 加密/解密 | 标准接口使用 `C1 || C3 || C2` 和 SM3 KDF;旧 `C1 || C2 || C3` 接口仅用于兼容 | ## API 约定 @@ -161,6 +164,16 @@ Guomi.SM4.supported?() # 解密 {:ok, plaintext} = Guomi.SM2.decrypt(ciphertext, private_key) +# 标准签名 API 要求显式 user ID,并计算 SM3(ZA || message) +user_id = "1234567812345678" +{:ok, standard_signature} = Guomi.SM2.sign_standard("message", private_key, user_id) +{:ok, true} = + Guomi.SM2.verify_standard("message", standard_signature, public_key, user_id) + +# 标准加密使用 C1 || C3 || C2 与可扩展 SM3 KDF(当前拒绝空消息) +{:ok, standard_ciphertext} = Guomi.SM2.encrypt_standard("secret message", public_key) +{:ok, "secret message"} = Guomi.SM2.decrypt_standard(standard_ciphertext, private_key) + # 检查运行时支持(始终为 true) Guomi.SM2.supported?() #=> true @@ -176,7 +189,7 @@ Guomi.SM2.verify("message", <<0>>, <<0>>) #=> {:error, :invalid_key} ``` -SM2 可能返回 `:invalid_key`、`:invalid_signature`、`:invalid_ciphertext` 或 `:decryption_failed`。SM4 可能返回 `:invalid_key_size`、`:invalid_iv_size`、`:invalid_block_size` 或 `:invalid_padding`。 +SM2 可能返回 `:invalid_input`、`:invalid_key`、`:invalid_signature`、`:invalid_ciphertext` 或 `:decryption_failed`。SM4 可能返回 `:invalid_key_size`、`:invalid_iv_size`、`:invalid_block_size` 或 `:invalid_padding`。 ## CLI 工具 @@ -222,7 +235,7 @@ guomi sm2 --generate echo "message" | guomi sm2 --sign --private-key # SM2 验签 -guomi sm2 --verify --public-key --signature message.txt +guomi sm2 --verify --public-key --signature --file message.txt ``` ### 完整文档 @@ -341,8 +354,8 @@ See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes. ## 兼容性与安全边界 -- **SM2 签名**:使用 SM3 预哈希与 raw `r || s` 签名格式,未实现用户 ID/ZA 参数,不应假定与 OpenSSL 或其他 SM2 实现互通。 -- **SM2 加密**:采用本库内部的 ECDH + SM3 派生密钥 + XOR + SM3 MAC 构造及 `C1 || C2 || C3` 格式。当前实现会对超过 32 字节的消息重复使用同一段 XOR 掩码,使相隔 32 字节的密文泄露对应明文的 XOR 关系;即使 MAC 能检测篡改,也不能修复该机密性缺陷。此接口仅保留用于兼容和测试,不得用于保护敏感数据或生产协议。 +- **SM2 签名**:`sign_standard/3`、`verify_standard/4` 提供显式用户 ID/ZA 的 raw `r || s` 接口;旧 `sign/2`、`verify/3` 只对消息做 SM3 预哈希,仅用于兼容。 +- **SM2 加密**:`encrypt_standard/2`、`decrypt_standard/2` 使用 `C1 || C3 || C2` 与可扩展 SM3 KDF,但仍未经独立安全审查。旧 `encrypt/2`、`decrypt/2` 采用内部 `C1 || C2 || C3` 格式并对超过 32 字节的消息重复 XOR 掩码,仅用于兼容和测试,不得用于保护敏感数据或生产协议。 - **ECB**:会泄露明文模式,不要用于新系统中的敏感数据保护。 - **CBC**:每次加密必须使用不可预测且不复用的 16 字节 IV;当前接口不提供认证。 - **CTR**:同一密钥下不得复用初始计数器块;当前接口只提供机密性,不提供认证。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8fdad4f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,56 @@ +# Security Policy and Usage Boundaries + +Guomi 是纯 Elixir 的 SM2、SM3、SM4 实现。除非发布说明另有明确记录,本项目没有密码产品认证,也没有完成独立第三方安全审计。 + +## 漏洞报告 + +请不要在公开 Issue 中披露尚未修复的安全漏洞。通过 GitHub Security Advisory 私下报告,并附上受影响版本、复现方式、潜在影响和建议修复方向。 + +## API 安全边界 + +### SM2 + +- 新代码应使用 `sign_standard/3`、`verify_standard/4`,并由协议显式规定相同的 user ID。 +- 新代码应使用 `encrypt_standard/2`、`decrypt_standard/2`;它们采用标准 SM3 KDF 和 `C1 || C3 || C2` 裸格式。 +- `sign/2`、`verify/3` 不计算 ZA,只为旧 Guomi 数据和脚本保留。 +- `encrypt/2`、`decrypt/2` 会对长消息重复 32 字节掩码,只为旧数据迁移保留,不得用于敏感数据。 +- 标准与旧解密入口不会互相猜测或回退。调用方必须根据可信的格式元数据选择入口。 +- SM2 适合加密短密钥材料,不适合直接加密大文件。大数据应使用经过审查的混合加密协议。 +- 标准 API 已覆盖公开向量和 OpenSSL 互操作,但这不等同于独立安全审计。 + +### SM4 + +- ECB 只适合标准向量测试或严格受控的遗留兼容,不适合一般消息。 +- CBC 和 CTR 只提供机密性,不检测篡改。不得把成功解密或成功去除 padding 当作消息可信。 +- CBC 的 IV 必须不可预测且不得在同一密钥下复用。 +- CTR 的初始 counter 在同一密钥下必须唯一;复用会泄露明文之间的 XOR 关系。 +- 需要完整性时,优先使用协议已经定义并经审查的认证加密方案。如果协议必须使用裸 SM4 CBC/CTR,应在协议层采用独立密钥的 encrypt-then-MAC,认证算法及标签长度必须由协议固定,并覆盖版本、算法标识、IV/counter、关联元数据和完整密文。 +- 本库目前不提供通用 SM4 encrypt-then-MAC 高层 API,避免在没有协议上下文时规定密钥派生、标签编码或重放语义。应用不应自行拼接未经评审的组合。 + +### SM3 + +- SM3 是无密钥哈希函数,不等同于 MAC、密码散列或密钥派生函数。 +- 需要消息认证时使用协议指定的 MAC;需要存储密码时使用专用、带盐且成本可调的密码哈希方案。 + +## 密钥、随机数与侧信道 + +- 私钥和对称密钥必须由受信任的密钥管理系统生成、存储和轮换,不应写入日志、命令历史或错误信息。 +- SM2 临时标量和密钥使用 Erlang `:crypto.strong_rand_bytes/1`。部署方仍需保证操作系统随机源正常。 +- 纯 Elixir 大整数和曲线运算没有声明为常量时间。对本地攻击者、共享主机或高价值密钥场景,不应假定可抵抗缓存、调度和计时侧信道。 +- `secure_compare` 只减少摘要比较中的早退差异,不能使整个 SM2 解密路径成为常量时间。 +- 解密端应限制输入大小、调用频率和错误可见性,防止资源消耗与错误预言机被协议放大。 + +## 明确不适用场景 + +- 需要经过国家密码管理认证、FIPS 验证或硬件密钥隔离的系统。 +- 无法接受纯 Elixir 大整数侧信道风险的多租户或敌对共址环境。 +- 直接加密大文件、数据库备份或无限长度网络流量。 +- 仅依赖裸 SM4 CBC/CTR 提供身份认证、完整性或防重放的协议。 +- 无可靠格式版本信息却需要自动区分旧 Guomi 密文和标准 SM2 密文的系统。 + +## 发布前安全检查 + +- 标准向量、负面测试和 OpenSSL 互操作测试通过。 +- 新公开 API 的输入上限、错误分类、二进制格式和迁移影响已记录。 +- release tag 与包版本一致,最低支持的 Elixir/OTP 组合通过 CI。 +- 涉及密码学构造、随机数或解析边界的重大变更应在发布前接受独立审查。 diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..a092053 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,20 @@ +# Benchmark Recording + +Run benchmarks from a clean checkout with no other CPU-intensive workload: + +```bash +mix run bench/bench.exs | tee bench/results/guomi---.txt +``` + +The script records Elixir, OTP, ERTS, operating system, architecture, scheduler count, message sizes, warmups, samples and SM2 iteration count. Results report min/median/max; use the median for routine comparison and retain the range as noise context. + +## Comparison rules + +- Compare results only when architecture, scheduler count, runtime versions and message sizes match. +- Run both the baseline commit and candidate commit on the same host and power profile. +- Keep raw output under `bench/results/` in release or performance PR artifacts; do not commit machine-specific results by default. +- Treat a repeatable median regression above 10% as investigation-worthy, not automatically as a release failure. +- Re-run at least twice before drawing a conclusion. Single wall-clock samples are not performance evidence. +- Record the compared Git commits and any VM/container/CPU-frequency constraints beside the raw output. + +`bench/results/` is a result exchange convention, not a CI gate. A future gate should use dedicated, stable hardware and a statistically justified threshold. diff --git a/bench/bench.exs b/bench/bench.exs index ba29301..2e2f0fc 100644 --- a/bench/bench.exs +++ b/bench/bench.exs @@ -9,66 +9,115 @@ defmodule Guomi.Bench do @moduledoc false - def time(label, n, fun) do - # warmup - fun.() + @samples 5 + @warmups 1 + @sm2_iterations 20 + @large_size 1024 * 1024 + @small_size 1024 + + defp sample(samples, iterations, fun) do + for _ <- 1..samples do + {us, _} = :timer.tc(fn -> Enum.each(1..iterations, fn _ -> fun.() end) end) + us / iterations + end + end - {us, _} = :timer.tc(fn -> Enum.each(1..n, fn _ -> fun.() end) end) + defp stats(values) do + sorted = Enum.sort(values) + {hd(sorted), Enum.at(sorted, div(length(sorted), 2)), List.last(sorted)} + end - per_op = us / n / 1000 + defp time(label, iterations, fun) do + Enum.each(1..@warmups, fn _ -> fun.() end) + {minimum, median, maximum} = @samples |> sample(iterations, fun) |> stats() - label = String.pad_trailing(label, 34) + IO.puts( + "#{String.pad_trailing(label, 30)} " <> + "min/median/max #{format_duration(minimum)} / " <> + "#{format_duration(median)} / #{format_duration(maximum)}" + ) + end - if per_op >= 1 do - IO.puts("#{label} #{per_op |> Float.round(2)} ms/op") - else - IO.puts("#{label} #{us / n |> Float.round(1)} us/op") - end + defp throughput(label, bytes, fun) do + Enum.each(1..@warmups, fn _ -> fun.() end) + rates = sample(@samples, 1, fun) |> Enum.map(&(bytes / &1)) + {minimum, median, maximum} = stats(rates) + + IO.puts( + "#{String.pad_trailing(label, 30)} " <> + "min/median/max #{format_rate(minimum)} / " <> + "#{format_rate(median)} / #{format_rate(maximum)}" + ) + end + + defp format_duration(us) when us >= 1000, do: "#{Float.round(us / 1000, 2)} ms/op" + defp format_duration(us), do: "#{Float.round(us, 1)} us/op" + defp format_rate(bytes_per_us), do: "#{Float.round(bytes_per_us, 1)} MB/s" + + defp print_environment do + {os_family, os_name} = :os.type() + + IO.puts("Elixir: #{System.version()}") + IO.puts("OTP: #{System.otp_release()}") + IO.puts("ERTS: #{:erlang.system_info(:version)}") + IO.puts("OS: #{os_family}/#{os_name}") + IO.puts("Architecture: #{:erlang.system_info(:system_architecture)}") + IO.puts("Schedulers: #{System.schedulers_online()}") + IO.puts("Samples: #{@samples}") + IO.puts("Warmups: #{@warmups}") + IO.puts("Large message: #{@large_size} bytes") + IO.puts("Small message: #{@small_size} bytes") + IO.puts("SM2 iterations: #{@sm2_iterations}\n") end def run do IO.puts("guomi #{Mix.Project.config()[:version]} benchmark\n") + print_environment() # -- SM3 ------------------------------------------------------------------ - data_1m = :binary.copy("a", 1024 * 1024) - data_1k = :binary.copy("a", 1024) - - {us, _} = :timer.tc(fn -> Guomi.SM3.hash(data_1m) end) - IO.puts("SM3 hash 1 MiB #{Float.round(1024 * 1024 / us, 1)} MB/s") + data_1m = :binary.copy("a", @large_size) + data_1k = :binary.copy("a", @small_size) - {us, _} = :timer.tc(fn -> Enum.each(1..100, fn _ -> Guomi.SM3.hash(data_1k) end) end) - IO.puts("SM3 hash 1 KiB x100 #{Float.round(100 * 1024 / us, 1)} MB/s") + throughput("SM3 hash 1 MiB", @large_size, fn -> Guomi.SM3.hash(data_1m) end) + throughput("SM3 hash 1 KiB", @small_size, fn -> Guomi.SM3.hash(data_1k) end) # -- SM4 ------------------------------------------------------------------ key = :binary.copy(<<1, 2, 3, 4, 5, 6, 7, 8>>, 2) iv = :binary.copy(<<0>>, 16) - {us, _} = :timer.tc(fn -> Guomi.SM4.encrypt(data_1m, key) end) - IO.puts("SM4 ECB encrypt 1 MiB #{Float.round(1024 * 1024 / us, 1)} MB/s") + throughput("SM4 ECB encrypt 1 MiB", @large_size, fn -> Guomi.SM4.encrypt(data_1m, key) end) - {us, _} = :timer.tc(fn -> Guomi.SM4.encrypt_cbc(data_1m, key, iv) end) - IO.puts("SM4 CBC encrypt 1 MiB #{Float.round(1024 * 1024 / us, 1)} MB/s") + throughput("SM4 CBC encrypt 1 MiB", @large_size, fn -> + Guomi.SM4.encrypt_cbc(data_1m, key, iv) + end) - {us, _} = :timer.tc(fn -> Guomi.SM4.encrypt_ctr(data_1m, key, iv) end) - IO.puts("SM4 CTR encrypt 1 MiB #{Float.round(1024 * 1024 / us, 1)} MB/s") + throughput("SM4 CTR encrypt 1 MiB", @large_size, fn -> + Guomi.SM4.encrypt_ctr(data_1m, key, iv) + end) # -- SM2 ------------------------------------------------------------------ - time("SM2 generate_keypair", 20, fn -> Guomi.SM2.generate_keypair() end) + time("SM2 generate_keypair", @sm2_iterations, fn -> Guomi.SM2.generate_keypair() end) {:ok, priv, pub} = Guomi.SM2.generate_keypair() message = "guomi benchmark message" - time("SM2 sign 32-byte message", 20, fn -> Guomi.SM2.sign(message, priv) end) + time("SM2 sign 32-byte message", @sm2_iterations, fn -> Guomi.SM2.sign(message, priv) end) {:ok, signature} = Guomi.SM2.sign(message, priv) - time("SM2 verify signature", 20, fn -> Guomi.SM2.verify(message, signature, pub) end) + time("SM2 verify signature", @sm2_iterations, fn -> + Guomi.SM2.verify(message, signature, pub) + end) - time("SM2 encrypt 32-byte message", 20, fn -> Guomi.SM2.encrypt(message, pub) end) + time("SM2 encrypt 32-byte message", @sm2_iterations, fn -> + Guomi.SM2.encrypt(message, pub) + end) {:ok, ciphertext} = Guomi.SM2.encrypt(message, pub) - time("SM2 decrypt 32-byte message", 20, fn -> Guomi.SM2.decrypt(ciphertext, priv) end) + time("SM2 decrypt 32-byte message", @sm2_iterations, fn -> + Guomi.SM2.decrypt(ciphertext, priv) + end) IO.puts("") end diff --git a/cli.md b/cli.md index 38fe116..ac4b24a 100644 --- a/cli.md +++ b/cli.md @@ -20,11 +20,12 @@ guomi [options] [input] - 未提供 `[input]` 时,从标准输入读取。 - `[input]` 为 `-` 或 `--` 时,从标准输入读取。 -- 只提供一个普通参数时,该参数按文件路径读取。 -- 提供多个普通参数时,参数以空格连接后作为消息内容。 +- 一个或多个普通参数均以空格连接后作为消息内容。 +- 使用 `--file ` 显式读取文件;`--file -` 从标准输入读取。 +- `--file` 与普通位置参数不能同时使用。 - SM2 的 `--message` 优先于上述输入来源。 -因此,直接处理短文本时推荐使用 stdin 或 `--message`;例如 `echo -n "hello" | guomi sm3 --hex`。 +因此 `guomi sm3 --hex hello` 会直接计算文本 `hello`,不会尝试打开同名文件。 ## 命令一览 @@ -51,11 +52,12 @@ guomi sm3 [options] [input] | 选项 | 说明 | |------|------| | `--hex` | 十六进制输出(默认输出原始二进制) | +| `--file ` | 从文件读取输入;使用 `-` 表示 stdin | | `--help` | 显示帮助信息 | ### 输入来源 -SM3 遵循[全局输入解析规则](#输入解析规则)。单个普通参数会被当作文件路径。 +SM3 遵循[全局输入解析规则](#输入解析规则)。 ### 示例 @@ -68,7 +70,7 @@ echo -n "hello" | guomi sm3 --hex guomi sm3 --hex hello world # 从文件读取 -guomi sm3 --hex document.txt +guomi sm3 --hex --file document.txt # 输出原始二进制(默认) echo -n "hello" | guomi sm3 @@ -97,14 +99,16 @@ guomi sm4 [options] [input] | 选项 | 说明 | |------|------| -| `--mode ` | 加密模式,默认 `ecb`;CLI 当前不提供 CTR | +| `--mode ` | 加密模式,默认 `ecb` | | `--key ` | **必填**。16 字节密钥,十六进制编码 | | `--iv ` | CBC 模式必填。16 字节初始向量,十六进制编码 | +| `--counter ` | CTR 模式必填。16 字节大端初始计数器,十六进制编码 | | `--decrypt` | 解密模式(默认加密) | | `--hex` | 快捷方式:加密时输出 hex,解密时输入为 hex | | `--input-hex` | 输入视为十六进制文本 | | `--output-hex` | 输出为十六进制文本 | | `--padding ` | 填充方式,默认 `pkcs7` | +| `--file ` | 从文件读取输入;使用 `-` 表示 stdin | | `--help` | 显示帮助信息 | ### 安全提示 @@ -113,6 +117,7 @@ guomi sm4 [options] [input] - **CBC 模式**必须为每次加密使用**不可预测且不复用**的 16 字节 IV - 同一密钥下 IV 复用会完全破坏机密性 - ECB/CBC 均不提供消息认证;应用层需要单独保证完整性 +- CTR 的同一 key/counter 组合不得复用;CTR 不使用 padding,也不提供消息认证 ### 示例 @@ -151,6 +156,11 @@ echo -n "736563726574" | guomi sm4 \ echo -n "abcdefghijklmnop" | guomi sm4 \ --key 0123456789abcdef0123456789abcdef \ --padding none + +# CTR(任意长度;不要复用相同的 key/counter) +echo -n "secret" | guomi sm4 --mode ctr --output-hex \ + --key 0123456789abcdef0123456789abcdef \ + --counter 00112233445566778899aabbccddeeff ``` --- @@ -175,9 +185,9 @@ guomi sm2 [options] [input] | `--private-key ` | 私钥(32 字节,十六进制) | | `--public-key ` | 公钥(65 字节,04 前缀 + 32 字节 x + 32 字节 y,十六进制) | | `--message ` | 待签名/验签/加密的消息文本 | +| `--file ` | 从文件读取消息或密文;使用 `-` 表示 stdin | | `--signature ` | 签名值(64 字节 raw r \|\| s,十六进制,仅验签) | | `--ciphertext ` | 密文(十六进制,仅解密) | -| `--hex` | 当前 SM2 子命令保留选项;密钥、签名和密文输出本身始终为 hex | | `--help` | 显示帮助信息 | ### 兼容性说明 @@ -185,6 +195,7 @@ guomi sm2 [options] [input] - 签名算法使用 SM3 预哈希 + raw 64 字节 `r || s` 格式,未暴露用户 ID/ZA 参数 - 加密使用 Guomi 内部 `C1 || C2 || C3` 格式(C1=65 字节临时公钥, C3=32 字节 SM3 MAC)。当前实现对超过 32 字节的消息重复 XOR 掩码,会泄露相隔 32 字节的明文 XOR 关系;仅用于兼容和测试,不得用于敏感数据或生产协议 - **不应假定可与 OpenSSL 或其他 SM2 实现互通** +- 标准 ZA 签名和 `C1 || C3 || C2` 加密目前仅通过库 API 提供;CLI 保持旧兼容行为,避免无提示改变已有脚本 ### 示例 @@ -213,7 +224,7 @@ guomi sm2 --verify \ guomi sm2 --verify \ --public-key \ --signature \ - message.txt + --file message.txt # 加密 echo "secret message" | guomi sm2 --encrypt --public-key @@ -226,7 +237,7 @@ guomi sm2 --decrypt \ # 解密(文件内容必须是 hex 文本) guomi sm2 --decrypt \ --private-key \ - ciphertext.hex + --file ciphertext.hex ``` --- diff --git a/future_work.md b/future_work.md new file mode 100644 index 0000000..80cc5cf --- /dev/null +++ b/future_work.md @@ -0,0 +1,70 @@ +# Future API and Algorithm Scope + +本文记录流式 API、国密证书与 SM9 的调研结论。它定义是否进入实现的前置条件,不承诺具体发布时间。 + +## 流式 SM3/SM4 + +### SM3 + +建议后续提供显式状态对象: + +```elixir +state = Guomi.SM3.init() +state = Guomi.SM3.update(state, chunk) +digest = Guomi.SM3.final(state) +``` + +状态需要保存 8 个链变量、未满 64 字节的尾部缓冲区和累计字节数。`final/1` 只能执行一次;累计长度溢出 SM3 的 64 位 bit-length 字段时必须返回错误。实现应使用标准分块向量验证任意切分与一次性 `hash/1` 等价。 + +### SM4 CTR + +CTR 流状态需要保存展开后的轮密钥、下一个 128 位大端 counter,以及上一个 keystream block 尚未消费的字节。仅保存 counter 会导致非 16 字节边界切分时输出错误。`final/1` 不添加数据。 + +初始化必须要求显式 16 字节 counter,并继续强调同一 key/counter 组合不得复用。流式 CTR 仍不提供完整性。 + +### SM4 CBC + +CBC 加密状态需要保存上一密文块和不足 16 字节的明文;`final/1` 根据显式 padding 策略输出最后一块。CBC 解密必须保留至少最后一个完整密文块,直到 `final/1` 才能验证并移除 PKCS#7 padding。 + +流式解密不得在 `final/1` 成功前把消息标记为可信。由于 CBC 本身不认证,面向不可信输入的高层接口不应鼓励调用方在完整性验证前消费流出的明文。 + +### 进入实现的条件 + +- 先决定状态结构是公开 struct 还是不透明值,以及重复 `final`、空 chunk、错误后继续调用的语义。 +- 为 0、1、15、16、17 字节和大消息建立所有切分位置的等价性测试。 +- 明确状态是否包含密钥材料,以及日志、Inspect 和进程消息复制的风险。 +- 不把流式裸 CBC/CTR 描述成认证加密。 + +## 国密证书解析 + +第一阶段如进入实现,只考虑 DER/PEM X.509 的只读解析和 SM2 公钥提取,不自行实现证书链验证。需要覆盖: + +- SubjectPublicKeyInfo 中的 SM2 曲线/算法 OID 和未压缩公钥点。 +- `sm2sig_sm3` 签名算法标识、DER `r/s` 与本库 raw `r || s` 的显式转换。 +- Key Usage、Extended Key Usage、Basic Constraints、Subject Alternative Name 等常用扩展的结构化读取。 +- RFC 5280 的通用 X.509 语义,以及 RFC 8998 对 TLS 1.3 ShangMi 证书使用的附加约束。 + +链构建、名称约束、撤销、策略处理和时间验证应委托成熟 PKIX 实现。OTP 24 到当前版本对 SM2 OID/曲线支持差异必须先形成兼容矩阵,不能假设 `:public_key` 在所有声明版本上行为一致。 + +进入实现前需要收集:公开正向证书链、未知关键扩展、错误 OID、错误曲线点、签名不匹配、过期/未生效及交叉签发样本。 + +## SM9 + +截至 2026 年 8 月,范围依据包括: + +- GB/T 38635.1-2020《SM9 标识密码算法 第1部分:总则》。 +- GB/T 38635.2-2020《SM9 标识密码算法 第2部分:算法》。 +- GB/T 41389-2022《SM9 密码算法使用规范》。 +- GB/T 47471-2026《SM9 密码算法加密签名消息格式》,已发布并将于 2026-11-01 实施。 + +SM9 需要有限域扩域、双线性对、群成员检查、主密钥/用户私钥派生、签名、加密和密钥交换,规模远超在现有 SM2 曲线模块上增加一个算法。当前结论是暂不进入实现。 + +重新评估 SM9 实现必须同时满足: + +- 明确首个交付范围(建议只选签名或只选加密,不能一次覆盖全部协议)。 +- 获得可引用的官方向量、消息格式样本和至少一种独立实现互操作环境。 +- 先设计扩域和配对层的常量时间、内存上限、点/群验证与 fuzz 测试策略。 +- 评估采用成熟原生库与纯 Elixir 实现的安全、可移植性和维护成本。 +- 预留独立密码学审查预算;在此之前不得声明生产适用。 + +官方标准目录: diff --git a/lib/guomi.ex b/lib/guomi.ex index 6d4fa55..082aaed 100644 --- a/lib/guomi.ex +++ b/lib/guomi.ex @@ -1,6 +1,14 @@ defmodule Guomi do @moduledoc """ Guomi algorithms facade. + + ## Examples + + iex> Guomi.algorithms() + [:sm2, :sm3, :sm4] + + iex> Guomi.supported() + %{sm2: true, sm3: true, sm4: true} """ @type algorithm :: :sm2 | :sm3 | :sm4 diff --git a/lib/guomi/cli.ex b/lib/guomi/cli.ex index c27dd77..346afd7 100644 --- a/lib/guomi/cli.ex +++ b/lib/guomi/cli.ex @@ -18,6 +18,8 @@ defmodule Guomi.CLI do @version Mix.Project.config()[:version] + @doc "Runs the escript entry point and converts unhandled errors to exit status 1." + @spec main([String.t()]) :: no_return() | :ok def main(args) do run(args) rescue @@ -26,6 +28,8 @@ defmodule Guomi.CLI do System.halt(1) end + @doc "Dispatches parsed command-line arguments. Primarily exposed for integration tests." + @spec run([String.t()]) :: no_return() | :ok def run([]) do print_help() end @@ -63,13 +67,22 @@ defmodule Guomi.CLI do end defp handle_sm3(args) do - {opts, remaining, _} = OptionParser.parse(args, strict: [hex: :boolean, help: :boolean]) + {opts, remaining, invalid} = + OptionParser.parse(args, strict: [hex: :boolean, file: :string, help: :boolean]) + if invalid != [] do + fail("Unknown or invalid SM3 option: #{format_invalid_options(invalid)}") + else + handle_parsed_sm3(opts, remaining) + end + end + + defp handle_parsed_sm3(opts, remaining) do if opts[:help] do print_sm3_help() else ensure_sm3_supported!() - input = read_input(remaining) + input = read_input(remaining, opts) if opts[:hex] do hash = Guomi.SM3.hash_hex(input) @@ -87,39 +100,49 @@ defmodule Guomi.CLI do end defp handle_sm4(args) do - {opts, remaining, _} = + {opts, remaining, invalid} = OptionParser.parse(args, strict: [ mode: :string, key: :string, iv: :string, + counter: :string, decrypt: :boolean, hex: :boolean, input_hex: :boolean, output_hex: :boolean, padding: :string, + file: :string, help: :boolean ] ) + if invalid != [] do + fail("Unknown or invalid SM4 option: #{format_invalid_options(invalid)}") + else + handle_parsed_sm4(opts, remaining) + end + end + + defp handle_parsed_sm4(opts, remaining) do if opts[:help] do print_sm4_help() else mode = Keyword.get(opts, :mode, "ecb") key = required_hex_or_exit(opts[:key], "key") - iv = validate_sm4_iv(mode, opts[:iv]) - padding = parse_padding_or_exit(Keyword.get(opts, :padding, "pkcs7")) + iv_or_counter = validate_sm4_parameter(mode, opts) + padding = parse_sm4_padding(mode, opts) decrypt? = Keyword.get(opts, :decrypt, false) hex? = Keyword.get(opts, :hex, false) input_hex? = sm4_input_hex?(opts, hex?, decrypt?) output_hex? = sm4_output_hex?(opts, hex?, decrypt?) - input = read_input(remaining) + input = read_input(remaining, opts) result = if decrypt? do - decrypt_sm4(input, key, mode, iv, padding, input_hex?) + decrypt_sm4(input, key, mode, iv_or_counter, padding, input_hex?) else - encrypt_sm4(input, key, mode, iv, padding, input_hex?) + encrypt_sm4(input, key, mode, iv_or_counter, padding, input_hex?) end case result do @@ -142,6 +165,11 @@ defmodule Guomi.CLI do Guomi.SM4.encrypt_cbc(input, key, iv, padding: padding) end + defp encrypt_sm4(input, key, "ctr", counter, :none, input_hex?) do + input = if input_hex?, do: parse_hex_or_exit(input, "plaintext"), else: input + Guomi.SM4.encrypt_ctr(input, key, counter) + end + defp encrypt_sm4(_input, _key, mode, _iv, _padding, _input_hex?) do {:error, {:invalid_mode, mode}} end @@ -156,6 +184,11 @@ defmodule Guomi.CLI do Guomi.SM4.decrypt_cbc(ciphertext, key, iv, padding: padding) end + defp decrypt_sm4(input, key, "ctr", counter, :none, hex_input) do + ciphertext = if hex_input, do: parse_hex_or_exit(input, "ciphertext"), else: input + Guomi.SM4.decrypt_ctr(ciphertext, key, counter) + end + defp decrypt_sm4(_input, _key, mode, _iv, _padding, _hex) do {:error, {:invalid_mode, mode}} end @@ -166,7 +199,7 @@ defmodule Guomi.CLI do end defp handle_sm2(args) do - {opts, remaining, _} = + {opts, remaining, invalid} = OptionParser.parse(args, strict: [ generate: :boolean, @@ -177,13 +210,21 @@ defmodule Guomi.CLI do public_key: :string, private_key: :string, message: :string, + file: :string, signature: :string, ciphertext: :string, - hex: :boolean, help: :boolean ] ) + if invalid != [] do + fail("Unknown or invalid SM2 option: #{format_invalid_options(invalid)}") + else + handle_parsed_sm2(opts, remaining) + end + end + + defp handle_parsed_sm2(opts, remaining) do if opts[:help] do print_sm2_help() else @@ -272,18 +313,15 @@ defmodule Guomi.CLI do defp do_decrypt(args, opts) do ciphertext = - case {opts[:ciphertext], args} do - {nil, [file]} when file not in ["-", "--"] -> - File.read!(file) + case {opts[:ciphertext], opts[:file], args} do + {ciph, _file, _args} when is_binary(ciph) -> + ciph - {nil, []} -> + {nil, nil, []} -> required_hex_or_exit(nil, "ciphertext") - {ciph, _} when is_binary(ciph) -> - ciph - - _ -> - IO.read(:stdio, :eof) + {nil, _file, _args} -> + read_input(args, opts) end ciphertext = parse_hex_or_exit(ciphertext, "ciphertext") @@ -304,18 +342,34 @@ defmodule Guomi.CLI do msg _ -> - read_input(args) + read_input(args, opts) end end defp read_input([]), do: IO.read(:stdio, :eof) - defp read_input([file]) when file in ["-", "--"], do: IO.read(:stdio, :eof) - defp read_input([file]), do: File.read!(file) - defp read_input(args), do: Enum.join(args, " ") + + defp read_input(args, opts) do + case {opts[:file], args} do + {nil, []} -> IO.read(:stdio, :eof) + {nil, [source]} when source in ["-", "--"] -> IO.read(:stdio, :eof) + {nil, values} -> Enum.join(values, " ") + {source, []} when source in ["-", "--"] -> IO.read(:stdio, :eof) + {path, []} -> File.read!(path) + {_path, _values} -> fail("Use either --file or positional input, not both") + end + end defp write_output(output, true), do: IO.puts(Base.encode16(output, case: :lower)) defp write_output(output, false), do: IO.write(output) + defp format_invalid_options(options) do + options + |> Enum.map_join(", ", fn + {option, nil} -> option + {option, value} -> "#{option}=#{value}" + end) + end + defp sm4_input_hex?(opts, hex?, decrypt?) do Keyword.get(opts, :input_hex, false) or (hex? and decrypt?) end @@ -355,6 +409,27 @@ defmodule Guomi.CLI do defp validate_sm4_iv("cbc", iv), do: parse_hex_or_exit(iv, "iv") defp validate_sm4_iv(_mode, _iv), do: nil + defp validate_sm4_parameter("cbc", opts), do: validate_sm4_iv("cbc", opts[:iv]) + + defp validate_sm4_parameter("ctr", opts) do + opts[:counter] + |> required_hex_or_exit("counter") + |> validate_sm4_counter() + end + + defp validate_sm4_parameter(_mode, _opts), do: nil + + defp validate_sm4_counter(counter) when byte_size(counter) == 16, do: counter + defp validate_sm4_counter(_counter), do: fail("Invalid counter size (must be 16 bytes)") + + defp parse_sm4_padding("ctr", opts) do + if opts[:padding], do: fail("SM4 CTR does not use padding"), else: :none + end + + defp parse_sm4_padding(_mode, opts) do + parse_padding_or_exit(Keyword.get(opts, :padding, "pkcs7")) + end + defp parse_padding_or_exit("pkcs7"), do: :pkcs7 defp parse_padding_or_exit("none"), do: :none @@ -374,10 +449,14 @@ defmodule Guomi.CLI do defp format_sm4_error(:invalid_block_size), do: "Invalid block size" defp format_sm4_error(:invalid_padding), do: "Invalid padding option" defp format_sm4_error(:unsupported), do: "SM4 is not supported on this system" - defp format_sm4_error({:invalid_mode, mode}), do: "Invalid mode: #{mode} (use 'ecb' or 'cbc')" + + defp format_sm4_error({:invalid_mode, mode}), + do: "Invalid mode: #{mode} (use 'ecb', 'cbc', or 'ctr')" + defp format_sm4_error(_), do: "Unknown error" defp format_sm2_error(:decryption_failed), do: "Decryption failed" + defp format_sm2_error(:invalid_input), do: "Invalid message input" defp format_sm2_error(:invalid_ciphertext), do: "Invalid ciphertext" defp format_sm2_error(:invalid_key), do: "Invalid key" defp format_sm2_error(:invalid_signature), do: "Invalid signature" @@ -404,7 +483,7 @@ defmodule Guomi.CLI do EXAMPLES: # SM3 hash echo -n "hello" | guomi sm3 - guomi sm3 --hex file.txt + guomi sm3 --hex --file file.txt # SM4 encryption echo "secret" | guomi sm4 --key 0123456789abcdef0123456789abcdef @@ -417,7 +496,7 @@ defmodule Guomi.CLI do echo "message" | guomi sm2 --sign --private-key # SM2 verify - guomi sm2 --verify --public-key --signature message.txt + guomi sm2 --verify --public-key --signature --file message.txt """) end @@ -430,17 +509,18 @@ defmodule Guomi.CLI do OPTIONS: --hex Output hash as hexadecimal (default: binary) + --file Read input from a file (use - for stdin) --help Show this help message INPUT: If no input is specified, reads from stdin. - A single argument is always treated as a file path. - Multiple arguments are joined with spaces and treated as the message. + Positional arguments are joined with spaces and treated as the message. + Use --file to read from a file, or - to read from stdin. EXAMPLES: echo -n "hello" | guomi sm3 guomi sm3 --hex hello world - guomi sm3 --hex file.txt + guomi sm3 --hex --file file.txt """) end @@ -452,20 +532,22 @@ defmodule Guomi.CLI do guomi sm4 [options] [input] OPTIONS: - --mode Encryption mode: ecb (default) or cbc + --mode Encryption mode: ecb (default), cbc, or ctr --key Encryption key (16 bytes hex, required) --iv Initialization vector (16 bytes hex, required for CBC) + --counter Initial counter (16 bytes hex, required for CTR) --decrypt Decrypt instead of encrypt --hex Compatibility shortcut: output hex when encrypting, input hex when decrypting --input-hex Treat input as hexadecimal text --output-hex Print output as hexadecimal text --padding Padding: pkcs7 (default) or none + --file Read input from a file (use - for stdin) --help Show this help message INPUT: If no input is specified, reads from stdin. - A single argument is treated as a file path. - Multiple arguments are joined with spaces and treated as the input. + Positional arguments are joined with spaces and treated as the input. + Use --file to read from a file, or - to read from stdin. EXAMPLES: # Encrypt with ECB @@ -479,6 +561,9 @@ defmodule Guomi.CLI do # Decrypt CBC mode guomi sm4 --decrypt --mode cbc --key 0123456789abcdef0123456789abcdef --iv 00000000000000000000000000000000 < ciphertext.bin + + # Encrypt with CTR (the key/counter pair must never be reused) + guomi sm4 --mode ctr --key 0123456789abcdef0123456789abcdef --counter 00000000000000000000000000000001 secret """) end @@ -498,15 +583,15 @@ defmodule Guomi.CLI do --public-key Public key (hex encoded) --private-key Private key (hex encoded) --message Message to sign/verify/encrypt + --file Read message/ciphertext from a file (use - for stdin) --signature Signature to verify (hex encoded) --ciphertext Ciphertext to decrypt (hex encoded) - --hex Reserved compatibility option; key/signature/ciphertext output is already hex --help Show this help message INPUT: If no input is specified, reads from stdin. - A single argument is treated as a file path. - Multiple arguments are joined with spaces and treated as the message. + Positional arguments are joined with spaces and treated as the message. + Use --file to read from a file, or - to read from stdin. --message takes precedence for sign, verify, and encrypt operations. EXAMPLES: @@ -517,7 +602,7 @@ defmodule Guomi.CLI do echo "message" | guomi sm2 --sign --private-key # Verify a signature - guomi sm2 --verify --public-key --signature message.txt + guomi sm2 --verify --public-key --signature --file message.txt # Encrypt a message echo "secret" | guomi sm2 --encrypt --public-key diff --git a/lib/guomi/sm2/curve.ex b/lib/guomi/sm2/curve.ex index 9d9b564..ef5a8bb 100644 --- a/lib/guomi/sm2/curve.ex +++ b/lib/guomi/sm2/curve.ex @@ -206,9 +206,16 @@ defmodule Guomi.SM2.Curve do end def shared_secret(private_key, public_point) do + case shared_point(private_key, public_point) do + {:ok, {sx, _sy}} -> {:ok, sx} + {:error, _} = error -> error + end + end + + def shared_point(private_key, public_point) do case mul(public_point, private_key) do :infinity -> {:error, :decryption_failed} - {sx, _sy} -> {:ok, sx} + {_sx, _sy} = point -> {:ok, point} end end diff --git a/lib/sm2.ex b/lib/sm2.ex index 778e66a..2e33e26 100644 --- a/lib/sm2.ex +++ b/lib/sm2.ex @@ -9,25 +9,37 @@ defmodule Guomi.SM2 do This is a pure Elixir implementation with no external dependencies. - The encryption API uses a Guomi-specific compatibility format and repeats a - 32-byte XOR mask for longer messages. It must not be used to protect - sensitive data or as part of a production protocol. + The explicit `sign_standard/3`, `verify_standard/4`, `encrypt_standard/2`, and + `decrypt_standard/2` APIs implement ZA-aware signatures and the standard SM3 + KDF. The shorter legacy APIs retain the historical Guomi-specific format; + legacy encryption repeats a 32-byte XOR mask and must not protect sensitive + data or be used as part of a production protocol. """ alias Guomi.SM2.Curve + @max_user_id_bytes 8191 + @type error_reason :: :invalid_key + | :invalid_input | :invalid_signature | :invalid_ciphertext | :decryption_failed @spec supported?() :: boolean() + @doc "Returns `true`; the current SM2 primitives are implemented entirely in Elixir." def supported?, do: true # -- Key generation ---------------------------------------------------------- @spec generate_keypair :: {:ok, binary(), binary()} + @doc """ + Generates an SM2 key pair. + + The private key is a 32-byte big-endian integer. The public key is a 65-byte + uncompressed point encoded as `0x04 || x || y`. + """ def generate_keypair do {priv, {_gx, _gy} = pub} = Curve.generate_keypair() priv_bin = <> @@ -38,8 +50,15 @@ defmodule Guomi.SM2 do # -- Signature --------------------------------------------------------------- @spec sign(binary() | iodata(), binary()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Signs `message` with a 32-byte private key and returns raw `r || s` bytes. + + This compatibility API signs `SM3(message)` and does not calculate the SM2 + user identity digest ZA. Do not assume interoperability with standard SM2 + signing APIs. Invalid iodata returns `{:error, :invalid_input}`. + """ def sign(message, private_key) when is_binary(private_key) do - with {:ok, priv_int} <- decode_private_key(private_key), + with {:ok, priv_int} <- decode_signing_private_key(private_key), {:ok, data} <- to_binary(message) do digest = Guomi.SM3.hash(data) Curve.sign(digest, priv_int) @@ -48,6 +67,12 @@ defmodule Guomi.SM2 do @spec verify(binary() | iodata(), binary(), binary()) :: {:ok, boolean()} | {:error, error_reason()} + @doc """ + Verifies a raw 64-byte `r || s` compatibility signature. + + Returns `{:ok, false}` for a well-formed but invalid signature and an error for + malformed keys, signatures, or message input. This API does not calculate ZA. + """ def verify(message, signature, public_key) when is_binary(signature) and is_binary(public_key) do with {:ok, pub_point} <- decode_public_key(public_key), @@ -58,9 +83,77 @@ defmodule Guomi.SM2 do end end + @doc """ + Calculates the SM2 user identity digest ZA for `user_id` and `public_key`. + + The user ID must be a binary no longer than 8191 bytes so its bit length fits + the standard 16-bit `ENTLA` field. + """ + @spec user_identity_digest(binary(), binary()) :: + {:ok, binary()} | {:error, error_reason()} + def user_identity_digest(user_id, public_key) + when is_binary(user_id) and is_binary(public_key) do + with :ok <- validate_user_id(user_id), + {:ok, public_point} <- decode_public_key(public_key) do + {:ok, identity_digest(user_id, public_point)} + end + end + + def user_identity_digest(_user_id, _public_key), do: {:error, :invalid_input} + + @doc """ + Produces a standards-compatible raw SM2 signature using an explicit user ID. + + The signed digest is `SM3(ZA || message)`. The result is 64-byte raw + `r || s`; DER encoding is intentionally not implicit. + """ + @spec sign_standard(binary() | iodata(), binary(), binary()) :: + {:ok, binary()} | {:error, error_reason()} + def sign_standard(message, private_key, user_id) + when is_binary(private_key) and is_binary(user_id) do + with {:ok, private_int} <- decode_signing_private_key(private_key), + :ok <- validate_user_id(user_id), + {:ok, data} <- to_binary(message) do + public_point = Curve.mul(Curve.generator(), private_int) + digest = Guomi.SM3.hash(identity_digest(user_id, public_point) <> data) + Curve.sign(digest, private_int) + end + end + + def sign_standard(_message, _private_key, _user_id), do: {:error, :invalid_input} + + @doc """ + Verifies a standards-compatible raw SM2 signature using the same user ID. + + Returns `{:ok, false}` when the inputs are well formed but the signature or + user ID does not match. + """ + @spec verify_standard(binary() | iodata(), binary(), binary(), binary()) :: + {:ok, boolean()} | {:error, error_reason()} + def verify_standard(message, signature, public_key, user_id) + when is_binary(signature) and is_binary(public_key) and is_binary(user_id) do + with :ok <- validate_user_id(user_id), + {:ok, public_point} <- decode_public_key(public_key), + :ok <- validate_signature(signature), + {:ok, data} <- to_binary(message) do + digest = Guomi.SM3.hash(identity_digest(user_id, public_point) <> data) + {:ok, Curve.verify(digest, signature, public_point)} + end + end + + def verify_standard(_message, _signature, _public_key, _user_id), + do: {:error, :invalid_input} + # -- Encryption (ECDH + SM3 KDF + XOR + SM3 MAC) ---------------------------- @spec encrypt(binary() | iodata(), binary()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Encrypts with the legacy Guomi-specific `C1 || C2 || C3` compatibility format. + + This format repeats a 32-byte XOR mask for longer messages and must not be used + for sensitive data or production protocols. Invalid iodata returns + `{:error, :invalid_input}`. + """ def encrypt(plaintext, public_key) do with {:ok, data} <- to_binary(plaintext), {:ok, pub_point} <- decode_public_key(public_key), @@ -75,6 +168,12 @@ defmodule Guomi.SM2 do end @spec decrypt(binary(), binary()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Decrypts the legacy Guomi-specific compatibility ciphertext format. + + Returns `:invalid_ciphertext` for malformed framing, `:invalid_key` for an + invalid private key, or `:decryption_failed` when authentication fails. + """ def decrypt(ciphertext, _private_key) when byte_size(ciphertext) < 97 do {:error, :invalid_ciphertext} end @@ -96,21 +195,148 @@ defmodule Guomi.SM2 do end end + @doc """ + Encrypts a non-empty message using the standard SM2 KDF and `C1 || C3 || C2`. + + C1 is a 65-byte uncompressed point, C3 is the 32-byte SM3 integrity digest, + and C2 has the same length as the plaintext. This API has not undergone an + independent security audit. + """ + @spec encrypt_standard(binary() | iodata(), binary()) :: + {:ok, binary()} | {:error, error_reason()} + def encrypt_standard(plaintext, public_key) when is_binary(public_key) do + with {:ok, data} <- to_binary(plaintext), + :ok <- validate_nonempty(data), + {:ok, public_point} <- decode_public_key(public_key) do + do_encrypt_standard(data, public_point) + end + end + + def encrypt_standard(_plaintext, _public_key), do: {:error, :invalid_input} + + @doc """ + Decrypts standard raw SM2 `C1 || C3 || C2` ciphertext. + + This function never falls back to the legacy Guomi format. Integrity failure, + an invalid ephemeral point, or an all-zero KDF returns `:decryption_failed`. + """ + @spec decrypt_standard(binary(), binary()) :: + {:ok, binary()} | {:error, error_reason()} + def decrypt_standard(ciphertext, private_key) + when is_binary(ciphertext) and is_binary(private_key) do + with {:ok, private_int} <- decode_private_key(private_key), + {:ok, c1, c3, c2} <- split_standard_ciphertext(ciphertext), + {:ok, ephemeral_point} <- decode_ciphertext_point(c1), + {:ok, {x2, y2}} <- Curve.shared_point(private_int, ephemeral_point), + {:ok, mask} <- sm2_kdf(<>, byte_size(c2)), + false <- zero_binary?(mask), + plaintext <- :crypto.exor(c2, mask), + expected_c3 <- Guomi.SM3.hash(<> <> plaintext <> <>), + true <- secure_compare(c3, expected_c3) do + {:ok, plaintext} + else + {:error, :invalid_key} = error -> error + {:error, :invalid_ciphertext} = error -> error + _ -> {:error, :decryption_failed} + end + end + + def decrypt_standard(_ciphertext, _private_key), do: {:error, :invalid_input} + defp to_binary(data) do {:ok, IO.iodata_to_binary(data)} rescue - ArgumentError -> {:error, :invalid_key} + ArgumentError -> {:error, :invalid_input} + end + + defp validate_user_id(user_id) when byte_size(user_id) <= @max_user_id_bytes, do: :ok + defp validate_user_id(_user_id), do: {:error, :invalid_input} + + defp validate_nonempty(<<>>), do: {:error, :invalid_input} + defp validate_nonempty(_data), do: :ok + + defp identity_digest(user_id, {public_x, public_y}) do + {generator_x, generator_y} = Curve.generator() + entl = byte_size(user_id) * 8 + curve_a = Curve.a() + curve_b = Curve.b() + + Guomi.SM3.hash( + <> <> + user_id <> + <> + ) + end + + defp do_encrypt_standard(data, public_point) do + {ephemeral_private, ephemeral_public} = Curve.generate_keypair() + + with {:ok, {x2, y2}} <- Curve.shared_point(ephemeral_private, public_point), + {:ok, mask} <- sm2_kdf(<>, byte_size(data)) do + if zero_binary?(mask) do + do_encrypt_standard(data, public_point) + else + c1 = Curve.encode_public(ephemeral_public) + c2 = :crypto.exor(data, mask) + c3 = Guomi.SM3.hash(<> <> data <> <>) + {:ok, c1 <> c3 <> c2} + end + end + end + + defp sm2_kdf(_z, 0), do: {:error, :invalid_input} + + defp sm2_kdf(z, length) do + blocks = div(length + 31, 32) + + if blocks > 0xFFFFFFFF do + {:error, :invalid_input} + else + material = + for counter <- 1..blocks, into: <<>> do + Guomi.SM3.hash(z <> <>) + end + + {:ok, :binary.part(material, 0, length)} + end + end + + defp zero_binary?(data), do: do_zero_binary?(data) + defp do_zero_binary?(<<>>), do: true + defp do_zero_binary?(<<0, rest::binary>>), do: do_zero_binary?(rest) + defp do_zero_binary?(_data), do: false + + defp split_standard_ciphertext(<>) + when byte_size(c2) > 0, + do: {:ok, c1, c3, c2} + + defp split_standard_ciphertext(_ciphertext), do: {:error, :invalid_ciphertext} + + defp decode_ciphertext_point(c1) do + case decode_public_key(c1) do + {:ok, point} -> {:ok, point} + {:error, :invalid_key} -> {:error, :decryption_failed} + end end - # SM2 private keys must be in [1, n - 2] (GM/T 0003-2012). - # A key of n - 1 would make (1 + d) ≡ 0 (mod n), so signing could never - # produce a valid s and must be rejected instead of retrying forever. defp decode_private_key(<>) do - if key > 0 and key < Curve.n() - 1, do: {:ok, key}, else: {:error, :invalid_key} + if key > 0 and key < Curve.n(), do: {:ok, key}, else: {:error, :invalid_key} end defp decode_private_key(_), do: {:error, :invalid_key} + # Signing additionally excludes n - 1 because (1 + d) has no inverse mod n. + defp decode_signing_private_key(private_key) do + case decode_private_key(private_key) do + {:ok, key} -> + if key < Curve.n() - 1, do: {:ok, key}, else: {:error, :invalid_key} + + _ -> + {:error, :invalid_key} + end + end + defp decode_public_key(<<0x04, x_bin::binary-size(32), y_bin::binary-size(32)>>) do point = {:binary.decode_unsigned(x_bin, :big), :binary.decode_unsigned(y_bin, :big)} diff --git a/lib/sm3.ex b/lib/sm3.ex index 8c189fe..b3910d3 100644 --- a/lib/sm3.ex +++ b/lib/sm3.ex @@ -24,13 +24,29 @@ defmodule Guomi.SM3 do |> List.to_tuple() @spec supported?() :: boolean() + @doc "Returns `true`; SM3 is implemented entirely in Elixir." def supported?, do: true @spec hash(input()) :: binary() + @doc """ + Hashes binary or iodata input and returns the 32-byte SM3 digest. + + Raises `ArgumentError` when `data` is not valid iodata. + """ def hash(data) when is_binary(data), do: do_hash(data) def hash(data), do: data |> IO.iodata_to_binary() |> do_hash() @spec hash_hex(input()) :: String.t() + @doc """ + Hashes binary or iodata input and returns a 64-character lowercase hex digest. + + Raises `ArgumentError` when `data` is not valid iodata. + + ## Example + + iex> Guomi.SM3.hash_hex("abc") + "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0" + """ def hash_hex(data) do data |> hash() |> Base.encode16(case: :lower) end diff --git a/lib/sm4.ex b/lib/sm4.ex index 4760e3a..cae531b 100644 --- a/lib/sm4.ex +++ b/lib/sm4.ex @@ -329,9 +329,17 @@ defmodule Guomi.SM4 do } @spec supported?() :: boolean() + @doc "Returns `true`; SM4 is implemented entirely in Elixir." def supported?, do: true @spec encrypt(binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Encrypts `plaintext` with SM4-ECB and a 16-byte `key`. + + `:padding` may be `:pkcs7` (default) or `:none`. With `:none`, the input must + be block-aligned. ECB does not provide semantic security or integrity and is + unsuitable for structured or sensitive messages. + """ def encrypt(plaintext, key, opts \\ []) when is_binary(plaintext) and is_binary(key) do with :ok <- validate_key(key), {:ok, data} <- pad(plaintext, opts) do @@ -342,6 +350,12 @@ defmodule Guomi.SM4 do end @spec decrypt(binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Decrypts SM4-ECB `ciphertext` with a 16-byte `key`. + + `:padding` must match encryption and may be `:pkcs7` (default) or `:none`. + Returns an error for invalid key, block size, padding option, or PKCS#7 data. + """ def decrypt(ciphertext, key, opts \\ []) when is_binary(ciphertext) and is_binary(key) do with :ok <- validate_key(key), :ok <- validate_block(ciphertext), @@ -355,6 +369,12 @@ defmodule Guomi.SM4 do @spec encrypt_cbc(binary(), binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Encrypts with SM4-CBC using a 16-byte `key` and 16-byte unpredictable `iv`. + + `:padding` may be `:pkcs7` (default) or `:none`. CBC provides confidentiality + only; callers that need integrity must authenticate the IV and ciphertext. + """ def encrypt_cbc(plaintext, key, iv, opts \\ []) when is_binary(plaintext) and is_binary(key) and is_binary(iv) do with :ok <- validate_key(key), @@ -368,6 +388,11 @@ defmodule Guomi.SM4 do @spec decrypt_cbc(binary(), binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Decrypts SM4-CBC using the same 16-byte `key`, 16-byte `iv`, and padding mode. + + Successful padding removal does not authenticate the message. + """ def decrypt_cbc(ciphertext, key, iv, opts \\ []) when is_binary(ciphertext) and is_binary(key) and is_binary(iv) do with :ok <- validate_key(key), @@ -383,6 +408,13 @@ defmodule Guomi.SM4 do @spec encrypt_ctr(binary(), binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Encrypts arbitrary-length input with SM4-CTR. + + `counter` is a 16-byte initial counter block interpreted as a big-endian + 128-bit integer. It must never be reused with the same key. CTR has no padding + and provides no integrity; consequently `opts` must be empty. + """ def encrypt_ctr(plaintext, key, counter, opts \\ []) when is_binary(plaintext) and is_binary(key) and is_binary(counter) do with :ok <- validate_key(key), @@ -394,6 +426,12 @@ defmodule Guomi.SM4 do @spec decrypt_ctr(binary(), binary(), binary(), keyword()) :: {:ok, binary()} | {:error, error_reason()} + @doc """ + Decrypts SM4-CTR data with the original 16-byte key and initial counter block. + + CTR encryption and decryption are the same operation. This function does not + authenticate ciphertext. + """ def decrypt_ctr(ciphertext, key, counter, opts \\ []) when is_binary(ciphertext) and is_binary(key) and is_binary(counter) do encrypt_ctr(ciphertext, key, counter, opts) diff --git a/mix.exs b/mix.exs index d766e90..b7f5993 100644 --- a/mix.exs +++ b/mix.exs @@ -39,15 +39,25 @@ defmodule Guomi.MixProject do [ licenses: ["MIT"], links: %{"GitHub" => "https://github.com/ZeroMarker/guomi"}, - files: - ~w(lib .formatter.exs mix.exs README.md cli.md CHANGELOG.md todo.md hex.pm.md LICENSE) + files: ~w(lib bench .formatter.exs mix.exs README.md cli.md sm2_migration.md future_work.md + SECURITY.md CHANGELOG.md todo.md hex.pm.md LICENSE) ] end defp docs do [ main: "readme", - extras: ["README.md", "cli.md", "CHANGELOG.md", "todo.md", "hex.pm.md", "LICENSE"], + extras: [ + "README.md", + "cli.md", + "sm2_migration.md", + "future_work.md", + "SECURITY.md", + "CHANGELOG.md", + "todo.md", + "hex.pm.md", + "LICENSE" + ], source_ref: "v#{@version}" ] end diff --git a/sm2_migration.md b/sm2_migration.md new file mode 100644 index 0000000..eeccdd3 --- /dev/null +++ b/sm2_migration.md @@ -0,0 +1,96 @@ +# SM2 标准兼容与迁移设计 + +本文定义 Guomi 从内部兼容 SM2 构造迁移到标准 SM2 签名和加密接口的边界。本文是实现约束,不代表尚未落地的 API 已可使用。 + +## 目标与非目标 + +目标: + +- 提供包含用户 ID 与 ZA 的标准 SM2 签名和验签。 +- 提供使用标准 KDF、不会重复 XOR 掩码的 SM2 加密和解密。 +- 保留读取现有 Guomi 数据所需的显式兼容入口。 +- 让调用方能够从 API 或封装版本确定格式,禁止通过密文长度猜测格式。 + +非目标: + +- 不把本库描述为经过认证或独立审计的密码产品。 +- 不在同一个解密函数中自动尝试多种密文排列。 +- 不把裸 SM4 CBC/CTR 包装成经过认证的加密模式。 + +## 标准依据 + +- GB/T 32918.2-2016:SM2 数字签名算法。 +- GB/T 32918.4-2016:SM2 公钥加密算法。 +- GM/T 0003.2-2012:定义 `ZA = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)`。 +- OpenSSL `pkeyutl` SM2 文档:签名和验签必须使用一致的 distinguishing ID。 +- RFC 9563:确认推荐曲线参数及 32 字节 `r || s` 编码的一个公开应用配置。 + +参考链接: + +- +- +- + +## 签名 API 决策 + +现有 `sign/2` 和 `verify/3` 只处理 `SM3(message)`,继续作为旧兼容接口存在并标记弃用。标准接口使用不同名称,避免同一调用在升级后产生不同签名: + +```elixir +Guomi.SM2.sign_standard(message, private_key, user_id) +Guomi.SM2.verify_standard(message, signature, public_key, user_id) +Guomi.SM2.user_identity_digest(user_id, public_key) +``` + +约束: + +- `user_id` 必须由调用方显式传入,不设置库级隐式默认值。 +- `user_id` 是二进制,长度以 bit 计并编码为 16 位大端 `ENTLA`,因此不得超过 8191 字节。 +- ZA 固定使用当前 SM2 推荐曲线参数和调用方公钥。 +- 第一阶段签名编码固定为 64 字节 raw `r || s`。 +- DER 编码不混入第一阶段 API;如后续增加,应通过显式选项或单独转换函数提供。 +- 格式正确但验签失败返回 `{:ok, false}`;输入格式错误返回明确的 `{:error, reason}`。 + +## 加密 API 与密文决策 + +现有 `encrypt/2`、`decrypt/2` 继续只处理旧 Guomi `C1 || C2 || C3` 兼容格式,并标记弃用。标准接口使用不同名称: + +```elixir +Guomi.SM2.encrypt_standard(plaintext, public_key) +Guomi.SM2.decrypt_standard(ciphertext, private_key) +``` + +第一阶段标准裸密文固定为 `C1 || C3 || C2`: + +- C1:65 字节未压缩临时公钥 `0x04 || x1 || y1`。 +- C3:32 字节 `SM3(x2 || plaintext || y2)`。 +- C2:`plaintext XOR KDF(x2 || y2, byte_size(plaintext))`。 +- KDF:按 32 位大端计数器 `1..ceil(klen/32)` 扩展 SM3 输出,禁止计数器回绕。 +- 若非空消息得到全零 KDF,生成端必须重新选择临时密钥;解密端必须拒绝。 +- 空消息行为必须先由跨实现测试确认;确认前标准加密接口返回 `:invalid_input`。 + +`decrypt_standard/2` 只解析 `C1 || C3 || C2`,`decrypt/2` 只解析旧格式。两者不得互相回退。 + +如果未来需要单一持久化字段承载多版本密文,应由应用层或新增 envelope API 添加 magic 和版本号,例如: + +```text +"GMS2" || version || format || ciphertext +``` + +标准裸密文本身不添加私有前缀,以保留互操作能力。 + +## 迁移步骤 + +1. 发布标准签名、ZA、标准加密和标准解密 API;旧 API 保持原行为。 +2. 使用官方向量验证 ZA/签名,使用独立实现完成签名和密文双向测试。 +3. 在一个次版本中将旧 API 标记 deprecated,并在文档中删除新用法示例。 +4. 调用方按数据来源选择显式解密入口,再将成功读取的旧密文重新加密为标准格式。 +5. 只有在一个主版本升级中才考虑删除旧加密入口;不得静默改变 `decrypt/2` 的格式。 + +## 测试与验收 + +- ZA、签名和验签必须有公开标准向量。 +- KDF 必须覆盖 0、1、31、32、33 字节及多块长消息边界。 +- 密文测试必须覆盖 C1 非法点、截断、C3 篡改、C2 篡改、全零 KDF 和错误私钥。 +- 与至少一种独立实现完成签名、验签、加密、解密双向验证。 +- 互操作测试不可运行时必须明确 skip 原因,不得把未执行当作通过。 +- 独立安全审查完成前,标准接口仍需保留“未经审计”的生产使用警告。 diff --git a/test/cli_test.exs b/test/cli_test.exs index e475cfa..8801954 100644 --- a/test/cli_test.exs +++ b/test/cli_test.exs @@ -12,7 +12,7 @@ defmodule Guomi.CLITest do Path.join(System.tmp_dir!(), "guomi-cli-#{System.unique_integer([:positive])}") File.write!(input_path, input) - {args ++ [input_path], input_path} + {args ++ ["--file", input_path], input_path} else {args, nil} end @@ -56,6 +56,19 @@ defmodule Guomi.CLITest do end end + test "sm3 treats a single positional argument as message text" do + {output, 0} = run_cli(["sm3", "--hex", "abc"]) + + assert String.trim(output) == + "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0" + end + + test "input rejects combining --file with positional text" do + {output, status} = run_cli(["sm3", "--hex", "--file", "some-file", "text"]) + assert status != 0 + assert output =~ "Use either --file or positional input, not both" + end + test "sm3 handles empty input" do if Guomi.SM3.supported?() do {output, 0} = run_cli(["sm3", "--hex"], "") @@ -70,9 +83,9 @@ defmodule Guomi.CLITest do end test "sm4 reports invalid mode" do - {output, status} = run_cli(["sm4", "--key", @key, "--mode", "ctr"], "secret") + {output, status} = run_cli(["sm4", "--key", @key, "--mode", "gcm"], "secret") assert status != 0 - assert output =~ "Invalid mode: ctr" + assert output =~ "Invalid mode: gcm" end test "sm4 reports invalid hex ciphertext" do @@ -87,6 +100,12 @@ defmodule Guomi.CLITest do assert output =~ "Missing required option --ciphertext" end + test "sm2 rejects the removed --hex option" do + {output, status} = run_cli(["sm2", "--hex", "--generate"]) + assert status != 0 + assert output =~ "Unknown or invalid SM2 option: --hex" + end + test "sm4 encrypts to hex and decrypts hex ciphertext" do if Guomi.SM4.supported?() do {ciphertext, 0} = run_cli(["sm4", "--key", @key, "--hex"], "secret") @@ -139,6 +158,46 @@ defmodule Guomi.CLITest do assert output =~ "Missing required option --iv" end + test "sm4 ctr mode encrypts and decrypts arbitrary-length input" do + counter = "00112233445566778899aabbccddeeff" + + {ciphertext, 0} = + run_cli(["sm4", "--mode", "ctr", "--counter", counter, "--key", @key, "--hex"], "secret") + + {plaintext, 0} = + run_cli( + ["sm4", "--decrypt", "--mode", "ctr", "--counter", counter, "--key", @key, "--hex"], + String.trim(ciphertext) + ) + + assert plaintext == "secret" + end + + test "sm4 ctr mode requires a 16-byte counter and rejects padding" do + {missing_output, missing_status} = run_cli(["sm4", "--mode", "ctr", "--key", @key], "secret") + assert missing_status != 0 + assert missing_output =~ "Missing required option --counter" + + {padding_output, padding_status} = + run_cli( + [ + "sm4", + "--mode", + "ctr", + "--counter", + String.duplicate("00", 16), + "--key", + @key, + "--padding", + "none" + ], + "secret" + ) + + assert padding_status != 0 + assert padding_output =~ "SM4 CTR does not use padding" + end + test "unknown command reports error" do {output, status} = run_cli(["unknown"]) assert status != 0 diff --git a/test/guomi_test.exs b/test/guomi_test.exs index 4491fee..310ae18 100644 --- a/test/guomi_test.exs +++ b/test/guomi_test.exs @@ -1,6 +1,9 @@ defmodule GuomiTest do use ExUnit.Case, async: true + doctest Guomi + doctest Guomi.SM3 + describe "algorithms/0" do test "lists exported algorithms" do assert Guomi.algorithms() == [:sm2, :sm3, :sm4] diff --git a/test/openssl_compat_test.exs b/test/openssl_compat_test.exs index 85e85b8..6c8199e 100644 --- a/test/openssl_compat_test.exs +++ b/test/openssl_compat_test.exs @@ -40,7 +40,7 @@ defmodule Guomi.OpenSSLCompatTest do elixir = System.find_executable("elixir") || System.find_executable("elixir.bat") with_temp_file(input, fn input_path -> - code = "Guomi.CLI.run(#{inspect(args ++ [input_path])})" + code = "Guomi.CLI.run(#{inspect(args ++ ["--file", input_path])})" System.cmd(elixir, ["-pa", Path.expand("_build/test/lib/guomi/ebin"), "-e", code], stderr_to_stdout: true @@ -93,6 +93,116 @@ defmodule Guomi.OpenSSLCompatTest do defp read_openssl_output({_output, _status}, _output_path, missing_reason), do: {:skip, missing_reason} + defp sm2_private_pem(private_key, public_key) do + oid_ec_public_key = <<0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01>> + oid_sm2 = <<0x06, 0x08, 0x2A, 0x81, 0x1C, 0xCF, 0x55, 0x01, 0x82, 0x2D>> + + ec_private_key = + <<0x02, 0x01, 0x01, 0x04, 0x20>> <> + private_key <> + <<0xA1, 68, 0x03, 66, 0x00>> <> + public_key + + algorithm = der_wrap(0x30, oid_ec_public_key <> oid_sm2) + + private_key_info = + <<0x02, 0x01, 0x00>> <> + algorithm <> + der_wrap(0x04, der_wrap(0x30, ec_private_key)) + + der = der_wrap(0x30, private_key_info) + + encoded = + der + |> Base.encode64() + |> String.graphemes() + |> Enum.chunk_every(64) + |> Enum.map_join("\n", &Enum.join/1) + + "-----BEGIN PRIVATE KEY-----\n#{encoded}\n-----END PRIVATE KEY-----\n" + end + + defp raw_signature_to_der(<>) do + r = der_integer(r) + s = der_integer(s) + body = <<0x02, byte_size(r)>> <> r <> <<0x02, byte_size(s)>> <> s + <<0x30, byte_size(body)>> <> body + end + + defp der_signature_to_raw( + <<0x30, _sequence_size, 0x02, r_size, r::binary-size(r_size), 0x02, s_size, + s::binary-size(s_size)>> + ) do + pad_integer(r) <> pad_integer(s) + end + + defp raw_ciphertext_to_der( + <<0x04, x::binary-size(32), y::binary-size(32), c3::binary-size(32), c2::binary>> + ) do + body = + der_wrap(0x02, der_integer(x)) <> + der_wrap(0x02, der_integer(y)) <> + der_wrap(0x04, c3) <> + der_wrap(0x04, c2) + + der_wrap(0x30, body) + end + + defp der_ciphertext_to_raw(der) do + {0x30, body, <<>>} = take_der(der) + {0x02, x, body} = take_der(body) + {0x02, y, body} = take_der(body) + {0x04, c3, body} = take_der(body) + {0x04, c2, <<>>} = take_der(body) + <<0x04>> <> pad_integer(x) <> pad_integer(y) <> c3 <> c2 + end + + defp der_wrap(tag, value) when byte_size(value) < 128, + do: <> <> value + + defp der_wrap(tag, value) when byte_size(value) < 256, + do: <> <> value + + defp take_der(<>) when length < 128 do + <> = rest + {tag, value, tail} + end + + defp take_der(<>) do + <> = rest + {tag, value, tail} + end + + defp der_integer(value) do + value = trim_zeroes(value) + if Bitwise.band(:binary.first(value), 0x80) == 0, do: value, else: <<0>> <> value + end + + defp trim_zeroes(<<0, rest::binary>>) when byte_size(rest) > 0, do: trim_zeroes(rest) + defp trim_zeroes(value), do: value + + defp pad_integer(<<0, rest::binary>>) when byte_size(rest) == 32, do: rest + + defp pad_integer(value) when byte_size(value) <= 32 do + :binary.copy(<<0>>, 32 - byte_size(value)) <> value + end + + defp with_sm2_files(message, private_key, public_key, fun) do + with_temp_file(message, fn message_path -> + with_sm2_key_file(message_path, private_key, public_key, fun) + end) + end + + defp with_sm2_key_file(message_path, private_key, public_key, fun) do + with_temp_file(sm2_private_pem(private_key, public_key), fn key_path -> + with_sm2_output_file(message_path, key_path, fun) + end) + end + + defp with_sm2_output_file(message_path, key_path, fun) do + with_temp_output(fn output_path -> fun.(message_path, key_path, output_path) end) + end + test "SM3 CLI output matches OpenSSL" do input = "The quick brown fox jumps over the lazy dog" @@ -148,4 +258,115 @@ defmodule Guomi.OpenSSLCompatTest do assert true end end + + test "standard SM2 signatures interoperate with OpenSSL in both directions" do + assert {:ok, private_key, public_key} = Guomi.SM2.generate_keypair() + message = "Guomi and OpenSSL SM2 interoperability" + user_id = "guomi-test-user" + + with_sm2_files(message, private_key, public_key, fn message_path, key_path, signature_path -> + assert {:ok, raw_signature} = + Guomi.SM2.sign_standard(message, private_key, user_id) + + File.write!(signature_path, raw_signature_to_der(raw_signature)) + + verify_args = [ + "pkeyutl", + "-verify", + "-inkey", + key_path, + "-rawin", + "-digest", + "sm3", + "-pkeyopt", + "distid:#{user_id}", + "-in", + message_path, + "-sigfile", + signature_path + ] + + case run_openssl(verify_args) do + {:skip, reason} -> + IO.puts("Skipping SM2 OpenSSL interoperability: #{reason}") + + {_output, 0} -> + sign_args = [ + "pkeyutl", + "-sign", + "-inkey", + key_path, + "-rawin", + "-digest", + "sm3", + "-pkeyopt", + "distid:#{user_id}", + "-in", + message_path, + "-out", + signature_path + ] + + assert {_output, 0} = run_openssl(sign_args) + openssl_signature = signature_path |> File.read!() |> der_signature_to_raw() + + assert {:ok, true} = + Guomi.SM2.verify_standard(message, openssl_signature, public_key, user_id) + + {output, status} -> + flunk("OpenSSL SM2 verification failed (#{status}): #{output}") + end + end) + end + + test "standard SM2 encryption interoperates with OpenSSL in both directions" do + assert {:ok, private_key, public_key} = Guomi.SM2.generate_keypair() + message = "Guomi and OpenSSL SM2 encryption" + + with_sm2_files(message, private_key, public_key, fn message_path, key_path, ciphertext_path -> + assert {:ok, raw_ciphertext} = Guomi.SM2.encrypt_standard(message, public_key) + File.write!(ciphertext_path, raw_ciphertext_to_der(raw_ciphertext)) + + with_temp_output(fn plaintext_path -> + decrypt_args = [ + "pkeyutl", + "-decrypt", + "-inkey", + key_path, + "-in", + ciphertext_path, + "-out", + plaintext_path + ] + + case run_openssl(decrypt_args) do + {:skip, reason} -> + IO.puts("Skipping SM2 OpenSSL encryption interoperability: #{reason}") + + {_output, 0} -> + assert File.read!(plaintext_path) == message + + encrypt_args = [ + "pkeyutl", + "-encrypt", + "-inkey", + key_path, + "-in", + message_path, + "-out", + ciphertext_path + ] + + assert {_output, 0} = run_openssl(encrypt_args) + openssl_ciphertext = ciphertext_path |> File.read!() |> der_ciphertext_to_raw() + + assert {:ok, ^message} = + Guomi.SM2.decrypt_standard(openssl_ciphertext, private_key) + + {output, status} -> + flunk("OpenSSL SM2 decryption failed (#{status}): #{output}") + end + end) + end) + end end diff --git a/test/sm2_test.exs b/test/sm2_test.exs index 48af4c0..45ca314 100644 --- a/test/sm2_test.exs +++ b/test/sm2_test.exs @@ -122,6 +122,63 @@ defmodule Guomi.SM2Test do end end + describe "standard signatures" do + test "matches the GB/T 32918.5 recommended-curve ZA and signature vector" do + public_key = + Base.decode16!( + "04" <> + "09F9DF311E5421A150DD7D161E4BC5C672179FAD1833FC076BB08FF356F35020" <> + "CCEA490CE26775A52DC6EA718CC1AA600AED05FBF35E084A6632F6072DA9AD13" + ) + + expected_za = + Base.decode16!("B2E14C5C79C6DF5B85F4FE7ED8DB7A262B9DA7E07CCB0EA9F4747B8CCDA8A4F3") + + signature = + Base.decode16!( + "F5A03B0648D2C4630EEAC513E1BB81A15944DA3827D5B74143AC7EACEEE720B3" <> + "B1B6AA29DF212FD8763182BC0D421CA1BB9038FD1F7F42D4840B69C485BBC1AA" + ) + + assert {:ok, ^expected_za} = + SM2.user_identity_digest("1234567812345678", public_key) + + assert {:ok, true} = + SM2.verify_standard( + "message digest", + signature, + public_key, + "1234567812345678" + ) + end + + test "ZA is deterministic and binds the user ID and public key" do + assert {:ok, _private_key, public_key} = SM2.generate_keypair() + assert {:ok, _other_private_key, other_public_key} = SM2.generate_keypair() + + assert {:ok, za} = SM2.user_identity_digest("1234567812345678", public_key) + assert byte_size(za) == 32 + assert {:ok, ^za} = SM2.user_identity_digest("1234567812345678", public_key) + refute SM2.user_identity_digest("other", public_key) == {:ok, za} + refute SM2.user_identity_digest("1234567812345678", other_public_key) == {:ok, za} + end + + test "standard sign and verify require the same user ID" do + assert {:ok, private_key, public_key} = SM2.generate_keypair() + assert {:ok, signature} = SM2.sign_standard("message", private_key, "alice") + assert {:ok, true} = SM2.verify_standard("message", signature, public_key, "alice") + assert {:ok, false} = SM2.verify_standard("message", signature, public_key, "bob") + end + + test "rejects user IDs whose bit length does not fit ENTLA" do + assert {:ok, private_key, public_key} = SM2.generate_keypair() + oversized = :binary.copy(<<0>>, 8192) + + assert {:error, :invalid_input} = SM2.user_identity_digest(oversized, public_key) + assert {:error, :invalid_input} = SM2.sign_standard("message", private_key, oversized) + end + end + describe "encrypt/2 and decrypt/2" do test "decrypt rejects ciphertext shorter than C1 plus C3" do assert {:error, :invalid_ciphertext} = SM2.decrypt(<<1, 2, 3>>, <<1::256-big>>) @@ -186,7 +243,85 @@ defmodule Guomi.SM2Test do end end + describe "standard encryption" do + test "decrypts the GB/T 32918.5 Annex C recommended-curve vector" do + private_key = + Base.decode16!("3945208F7B2144B13F36E38AC6D39F95889393692860B51A42FB81EF4DF7C5B8") + + ciphertext = + Base.decode16!( + "04" <> + "04EBFC718E8D1798620432268E77FEB6415E2EDE0E073C0F4F640ECD2E149A73" <> + "E858F9D81E5430A57B36DAAB8F950A3C64E6EE6A63094D99283AFF767E124DF0" <> + "59983C18F809E262923C53AEC295D30383B54E39D609D160AFCB1908D0BD8766" <> + "21886CA989CA9C7D58087307CA93092D651EFA" + ) + + assert {:ok, "encryption standard"} = + SM2.decrypt_standard(ciphertext, private_key) + end + + test "roundtrips long messages with C1 || C3 || C2 framing" do + assert {:ok, private_key, public_key} = SM2.generate_keypair() + plaintext = :binary.copy("standard sm2 message ", 10) + + assert {:ok, ciphertext} = SM2.encrypt_standard(plaintext, public_key) + assert byte_size(ciphertext) == 65 + 32 + byte_size(plaintext) + assert {:ok, ^plaintext} = SM2.decrypt_standard(ciphertext, private_key) + end + + test "decryption accepts n - 1, which is valid for encryption but not signing" do + private_int = Curve.n() - 1 + private_key = <> + public_key = Curve.generator() |> Curve.mul(private_int) |> Curve.encode_public() + + assert {:ok, ciphertext} = SM2.encrypt_standard("encryption-only key", public_key) + assert {:ok, "encryption-only key"} = SM2.decrypt_standard(ciphertext, private_key) + assert {:error, :invalid_key} = SM2.sign_standard("message", private_key, "user") + end + + test "standard KDF does not repeat a 32-byte mask block" do + assert {:ok, _private_key, public_key} = SM2.generate_keypair() + plaintext = :binary.copy(<<0>>, 64) + + assert {:ok, <<_c1::binary-size(65), _c3::binary-size(32), c2::binary>>} = + SM2.encrypt_standard(plaintext, public_key) + + <> = c2 + refute first == second + end + + test "rejects tampering, empty plaintext, and cross-format decryption" do + assert {:ok, private_key, public_key} = SM2.generate_keypair() + assert {:error, :invalid_input} = SM2.encrypt_standard("", public_key) + assert {:ok, ciphertext} = SM2.encrypt_standard("message", public_key) + last = byte_size(ciphertext) - 1 + <> = ciphertext + tampered = prefix <> <> + + assert {:error, :decryption_failed} = SM2.decrypt_standard(tampered, private_key) + assert {:error, :invalid_ciphertext} = SM2.decrypt_standard(<<0>>, private_key) + + invalid_c1 = <<0x04, 0::64*8, 0::32*8, 1>> + assert {:error, :decryption_failed} = SM2.decrypt_standard(invalid_c1, private_key) + + assert {:error, _reason} = SM2.decrypt(ciphertext, private_key) + + assert {:ok, legacy} = SM2.encrypt("message", public_key) + assert {:error, _reason} = SM2.decrypt_standard(legacy, private_key) + end + end + describe "invalid inputs" do + test "invalid iodata is reported as invalid input" do + assert {:ok, private_key, public_key} = SM2.generate_keypair() + assert {:ok, signature} = SM2.sign("valid", private_key) + + assert {:error, :invalid_input} = SM2.sign(["valid", :not_iodata], private_key) + assert {:error, :invalid_input} = SM2.verify([:not_iodata], signature, public_key) + assert {:error, :invalid_input} = SM2.encrypt([:not_iodata], public_key) + end + test "sign with invalid private key size or range" do assert {:error, :invalid_key} = SM2.sign("test", <<0::31*8>>) assert {:error, :invalid_key} = SM2.sign("test", <<0::33*8>>) diff --git a/todo.md b/todo.md index bd0483b..1ec8656 100644 --- a/todo.md +++ b/todo.md @@ -8,33 +8,67 @@ - **P1**:会显著改善公开 API、CLI 或发布质量的工作。 - **P2**:性能、扩展能力和长期维护工作。 -## P0:明确并强化密码学边界 +## 推荐执行顺序 -- [ ] 评估并设计标准兼容的 SM2 签名 API,包括用户 ID 与 ZA 处理。 -- [ ] 评估标准兼容的 SM2 密文格式与跨实现测试策略。 -- [ ] 替换当前 SM2 加密中重复 32 字节 XOR 掩码的设计,并为新密文格式提供明确的版本与迁移策略。 -- [ ] 为需要完整性保护的使用场景提供清晰方案;在此之前持续强调 CBC/CTR 仅提供机密性。 -- [ ] 对公开 API 开展独立安全审查,并记录不适用场景。 +1. 完成发布版本校验、最低版本 CI 和无效 CLI 选项清理。 +2. 先形成 SM2 标准签名与新密文格式设计,再修改公开 API。 +3. 实现版本化 SM2 密文、标准 KDF 和旧格式迁移策略。 +4. 增加标准测试向量、跨实现测试和独立安全审查。 +5. 统一 CLI 输入语义并补齐公开 API 与文档示例测试。 +6. 最后处理基准、流式 API、证书解析与 SM9 调研。 -## P1:API 与 CLI 完善 +## P0:SM2 标准兼容与安全迁移 + +### 标准签名 API + +- [x] 设计标准兼容的 SM2 签名与验签 API,明确用户 ID 必须显式传入以及 ZA 的计算方式。 +- [x] 决定第一阶段使用 raw `r || s`,DER 留作显式扩展,并避免静默改变现有 `sign/2`、`verify/3` 的兼容行为。 +- [x] 增加 GB/T 32918.5 ZA/签名测试向量及 OpenSSL 双向签名互操作测试。 + +### 版本化密文与 KDF + +- [x] 定义新的标准兼容密文格式,明确 C1/C2/C3 排列、点编码、空消息待互操作确认和显式解析规则。 +- [x] 新增按消息长度扩展且不会重复掩码的标准 KDF 与显式标准加密 API;旧 API 保持原语义并明确记录兼容限制。 +- [x] 为新旧密文定义不同的显式 API,并为未来持久化 envelope 预留版本字段,禁止解密时猜测格式。 +- [x] 制定旧兼容格式的弃用和迁移策略;迁移完成前继续明确其长消息风险和非生产用途。 +- [x] 增加 GB/T 32918.5 密文向量、畸形密文测试及 OpenSSL 双向加解密互操作测试。 + +### 完整性与安全边界 -- [ ] 决定是否为 CLI 增加 SM4 CTR 模式,并定义不易误用的 counter 输入方式。 -- [ ] 统一 CLI 中单参数“文件路径”和“消息文本”的交互语义。 -- [ ] 评估 SM2 `--hex` 保留选项,移除无效选项或赋予明确行为。 -- [ ] 为公开函数补充更完整的 `@doc`、参数说明和返回错误文档。 +- [x] 为需要完整性保护的 SM4 使用场景记录协议级认证加密或独立密钥 encrypt-then-MAC 方案,并强调裸 CBC/CTR 仅提供机密性。 +- [ ] 对公开 API、密钥与随机数处理、错误行为和时间侧信道开展独立安全审查。 +- [x] 在安全文档中记录当前验证范围、残余风险及明确不适用场景。 -## P1:测试与发布质量 +## P1:发布与测试质量 -- [ ] 增加受支持 Elixir/OTP 版本的 CI 矩阵,验证 README 中的最低版本要求。 -- [ ] 增加文档示例测试,避免 README 与 CLI 行为漂移。 -- [ ] 在 release workflow 中校验 Git tag 与 `mix.exs` 版本一致。 +- [x] 在 release workflow 中校验 Git tag 与 `mix.exs` 版本一致,并在创建 Release 和发布 Hex 包前失败退出。 +- [x] 增加受支持 Elixir/OTP 版本的 CI 矩阵,至少覆盖 README 声明的最低组合和当前主要组合。 +- [x] 增加文档示例测试:库 API 使用 doctest,CLI 示例使用集成测试,避免 README、`cli.md` 与实际行为漂移。 +- [x] 将 SM2 官方 ZA/签名向量和 OpenSSL 双向签名互操作纳入 CI;外部实现不可用时明确报告原因。 + +## P1:API 与 CLI 完善 + +- [x] 移除当前已解析但无实际行为的 SM2 `--hex` 选项,并对该无效选项返回明确错误。 +- [x] 统一 CLI 中单参数“文件路径”和“消息文本”的语义:位置参数表示消息文本,文件输入使用显式 `--file`,并记录迁移方法。 +- [x] 为 CLI 增加 SM4 CTR 模式,使用独立的 `--counter` 接收固定 16 字节大端初始计数器,并拒绝 padding 选项。 +- [x] 为所有公开函数补充 `@doc`、参数与二进制格式、返回值、错误原因和安全限制说明。 +- [x] 统一无效 iodata、密钥、签名和密文的错误分类,避免把非密钥输入错误报告为 `:invalid_key`。 ## P2:性能与扩展 -- [ ] 建立可复现的基准测试,记录硬件、OTP 版本、消息大小和运行参数。 -- [ ] 继续分析 SM2 曲线运算热点,并设计可迁移、不会重复掩码的加密 KDF。 -- [ ] 评估大消息的流式 SM3/SM4 API。 -- [ ] 调研国密证书解析与 SM9 支持,明确范围后再进入实现。 +- [x] 完善现有 `bench/bench.exs`:记录架构、操作系统、Elixir/OTP/ERTS 版本、调度器数量、消息大小、运行参数、预热次数和重复样本统计。 +- [x] 为基准建立稳定的结果记录与比较规范;明确禁止把单次 wall-clock 结果作为性能结论。 +- [ ] 继续分析 SM2 曲线运算热点;此项只处理性能,标准 KDF 与密文迁移归入 P0。 +- [x] 分别评估流式 SM3、SM4 CBC 和 SM4 CTR API,明确状态对象、分块规则、padding/finalize 行为及认证边界。 +- [x] 调研国密证书解析与 SM9 支持,形成范围、依赖、标准与测试来源说明;当前决定暂不实现 SM9。 + +## 完成定义 + +- 密码学功能必须有标准测试向量、负面测试和明确的二进制格式说明。 +- 兼容性功能必须至少与一种独立实现完成双向验证,或记录无法验证的原因。 +- 公开 API 变更必须同步更新模块文档、README、CLI 文档、CHANGELOG 和迁移说明。 +- CI、发布或文档待办只有在对应自动化检查落地后才能标记完成。 +- 安全相关功能在独立审查完成前不得宣称适合生产环境或敏感数据。 ## 已完成里程碑