[PR #77] Migrate from ECDSA to post-quantum cryptography (ML-DSA-65 + ML-KEM-768) #81

Open
opened 2026-02-28 01:17:07 +03:00 by kerem · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/therealpaulgg/ssh-sync/pull/77
Author: @therealpaulgg
Created: 2/13/2026
Status: 🔄 Open

Base: mainHead: claude/quantum-resistant-ssh-sync-LzCo1


📝 Commits (10+)

  • 10e6cb4 feat: replace classical cryptography with post-quantum algorithms (ML-KEM-768 + ML-DSA-65)
  • 23ae333 feat: add backward-compatible migration path for legacy EC keys
  • 5169b8b feat: add migrate command for upgrading keys to post-quantum crypto
  • fe9a0a0 feat: unify PQ keypairs into single master seed with HKDF derivation
  • 92454ed feat: add --classic flag to setup for optional EC key generation
  • 26ae62a fix: match generateKeyClassic signature to original generateKey
  • 361f82c refactor: remove dead legacy PQ PEM format support
  • 908795e refactor: separate signing and encapsulation keys in PublicKeyDto
  • 0c78a10 start using shared library for my sanity
  • 360a33b remove go work

📊 Changes

29 files changed (+1059 additions, -325 deletions)

View changed files

📝 go.mod (+6 -6)
📝 go.sum (+10 -7)
📝 main.go (+13 -1)
📝 pkg/actions/challenge-response.go (+11 -5)
📝 pkg/actions/download.go (+1 -1)
📝 pkg/actions/interactive/states/delete-ssh-key.go (+1 -1)
📝 pkg/actions/interactive/states/ssh-key-content.go (+1 -1)
📝 pkg/actions/interactive/states/ssh-key-manager.go (+1 -1)
📝 pkg/actions/interactive/states/ssh-key-options.go (+1 -1)
pkg/actions/migrate.go (+183 -0)
📝 pkg/actions/remove-machine.go (+1 -1)
📝 pkg/actions/reset.go (+2 -1)
📝 pkg/actions/setup.go (+105 -53)
📝 pkg/actions/upload.go (+3 -2)
pkg/dto/main.go (+0 -82)
📝 pkg/retrieval/data.go (+6 -4)
📝 pkg/retrieval/data_test.go (+47 -22)
pkg/retrieval/deps.go (+6 -0)
📝 pkg/retrieval/machines.go (+5 -4)
📝 pkg/retrieval/machines_test.go (+4 -1)

...and 9 more files

📄 Description

Summary

This PR adds post-quantum cryptography support using ML-DSA-65 (FIPS 204) for digital signatures and ML-KEM-768 (FIPS 203) for key encapsulation, while preserving full backward compatibility with existing ECDSA/ECDH-ES users.

Key Changes

Cryptographic Algorithm Addition

  • Signing: ML-DSA-65 via filippo.io/mldsa (new default for fresh setups)
  • Key Encapsulation: ML-KEM-768 via Go stdlib crypto/mlkem (replaces ECDH/JWE for PQ users)
  • Symmetric Encryption: AES-256-GCM retained for both paths (already quantum-resistant)
  • Legacy ECDSA/ECDH-ES path kept intact; lestrrat-go/jwx/v2 is still a dependency

Key Format Auto-Detection (pkg/utils/keyformat.go)

  • New DetectKeyFormat() inspects the PEM block type in ~/.ssh-sync/keypair
    • "EC PRIVATE KEY"FormatLegacyEC
    • "SSHSYNC PQ MASTER SEED"FormatPostQuantum
  • All format-sensitive operations (Encrypt, Decrypt, GetToken) branch on this at runtime

Key Storage Format (PQ path)

  • Single 64-byte random master seed stored as "SSHSYNC PQ MASTER SEED" PEM block in ~/.ssh-sync/keypair
  • ML-DSA-65 signing key + ML-KEM-768 decapsulation key both derived on demand from the seed via HKDF
  • keypair.pub contains the ML-DSA-65 public key (for server identity/JWT verification)
  • ML-KEM-768 encapsulation key is sent separately to the server during machine setup (stored server-side for key exchange relay)

Key Generation (pkg/actions/setup.go)

  • generateKey() generates a 64-byte random master seed and writes a "SSHSYNC PQ MASTER SEED" PEM block
  • Both ML-DSA-65 and ML-KEM-768 keypairs are derived from the seed via HKDF on demand — only the seed is persisted
  • generateKeyClassic() (legacy ECDSA P-256) unchanged
  • --classic flag routes to the old path; default is now PQ

Key Derivation (pkg/utils/pqseed.go)

  • DeriveMLDSAKey(seed) — HKDF-SHA256 with label "ssh-sync-mldsa-v1" → ML-DSA-65 keypair
  • DeriveMLKEMKey(seed) — HKDF-SHA256 with label "ssh-sync-mlkem768-v1" → ML-KEM-768 keypair

Key Retrieval (pkg/utils/keyretrieval.go)

  • RetrieveSigningKey() — derives ML-DSA-65 private key from master seed
  • RetrieveDecapsulationKey() / RetrieveEncapsulationKey() — derives ML-KEM-768 keys from master seed
  • BuildPQPublicKeys() — returns ML-DSA-65 public key PEM + ML-KEM-768 encapsulation key PEM as separate fields (sent to server during existing-machine setup)
  • Legacy RetrievePrivateKey() / RetrievePublicKey() (JWK) unchanged

JWT Token Generation (pkg/utils/tokengen.go)

  • getTokenPQ() — manual JWT construction signed with ML-DSA-65; uses "alg": "MLDSA" header
  • getTokenLegacy() — existing ES512 path unchanged
  • GetToken() auto-detects format and dispatches

Encryption/Decryption (pkg/utils/encrypt.go, pkg/utils/decrypt.go)

  • EncryptMLKEM / DecryptMLKEM — ML-KEM-768 encapsulation → HKDF → AES-256-GCM
  • Ciphertext format: [1088B ML-KEM ciphertext][12B nonce][AES-GCM ciphertext+tag]
  • EncryptWithPQPublicKey — encrypts for a remote machine's ML-KEM encapsulation key (challenge-response)
  • EncryptWithECPublicKey — legacy path unchanged
  • Encrypt / Decrypt auto-detect format

Challenge-Response (pkg/actions/challenge-response.go)

  • Now handles both PQ and legacy EC: if the server response includes an EncapsulationKey, uses EncryptWithPQPublicKey; otherwise falls back to EncryptWithECPublicKey

Migration Command (pkg/actions/migrate.go) — new

  • New migrate command for existing EC users to upgrade to PQ in-place:
    1. Decrypt master key with current EC keypair
    2. Obtain a JWT signed with the old key (before overwriting)
    3. Back up existing key files
    4. Generate new PQ keypair
    5. Re-encrypt master key with ML-KEM-768
    6. Upload new public key to server (authenticated with the pre-obtained old-key token)
    7. Clean up backups on success; rollback on failure
  • SSH keys stored on the server are unaffected (already AES-256-GCM encrypted)

Dependencies

  • Added: filippo.io/mldsa (ML-DSA-65), github.com/therealpaulgg/ssh-sync-common, golang.org/x/crypto (promoted to direct)
  • Retained: github.com/lestrrat-go/jwx/v2 (still used for legacy EC token signing and JWE encryption/decryption)
  • ML-KEM-768 uses Go stdlib crypto/mlkem (Go 1.25+)
  • Updated Go version to 1.25.0

Backward Compatibility

  • Existing EC users: all commands continue to work unchanged via format auto-detection
  • --classic flag available on setup to explicitly generate a legacy EC keypair
  • migrate command provides an explicit upgrade path from EC to PQ

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/therealpaulgg/ssh-sync/pull/77 **Author:** [@therealpaulgg](https://github.com/therealpaulgg) **Created:** 2/13/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `claude/quantum-resistant-ssh-sync-LzCo1` --- ### 📝 Commits (10+) - [`10e6cb4`](https://github.com/therealpaulgg/ssh-sync/commit/10e6cb405704e8b609af39f3ebf48ca76052043b) feat: replace classical cryptography with post-quantum algorithms (ML-KEM-768 + ML-DSA-65) - [`23ae333`](https://github.com/therealpaulgg/ssh-sync/commit/23ae3333af1bb3383295505782f7660e46472120) feat: add backward-compatible migration path for legacy EC keys - [`5169b8b`](https://github.com/therealpaulgg/ssh-sync/commit/5169b8b57f6ae03ad50fdb6136113107cc66fa30) feat: add `migrate` command for upgrading keys to post-quantum crypto - [`fe9a0a0`](https://github.com/therealpaulgg/ssh-sync/commit/fe9a0a014ddc730178ddaa215e8bdefaf2e7fa8b) feat: unify PQ keypairs into single master seed with HKDF derivation - [`92454ed`](https://github.com/therealpaulgg/ssh-sync/commit/92454edb0f345a6c1563b8238664835dfd70fb6d) feat: add --classic flag to setup for optional EC key generation - [`26ae62a`](https://github.com/therealpaulgg/ssh-sync/commit/26ae62a7ca1b8640ab8970dd478ac448af2d897d) fix: match generateKeyClassic signature to original generateKey - [`361f82c`](https://github.com/therealpaulgg/ssh-sync/commit/361f82ca3280d9e2007cca8ce4cb7dbdfb3d94ef) refactor: remove dead legacy PQ PEM format support - [`908795e`](https://github.com/therealpaulgg/ssh-sync/commit/908795e5d1cfb18a4b9090568aa0071698ea48ef) refactor: separate signing and encapsulation keys in PublicKeyDto - [`0c78a10`](https://github.com/therealpaulgg/ssh-sync/commit/0c78a105686de6d8d97409d8a3906a0eb2736a94) start using shared library for my sanity - [`360a33b`](https://github.com/therealpaulgg/ssh-sync/commit/360a33ba9090b6c969883740361290c7c78bb685) remove go work ### 📊 Changes **29 files changed** (+1059 additions, -325 deletions) <details> <summary>View changed files</summary> 📝 `go.mod` (+6 -6) 📝 `go.sum` (+10 -7) 📝 `main.go` (+13 -1) 📝 `pkg/actions/challenge-response.go` (+11 -5) 📝 `pkg/actions/download.go` (+1 -1) 📝 `pkg/actions/interactive/states/delete-ssh-key.go` (+1 -1) 📝 `pkg/actions/interactive/states/ssh-key-content.go` (+1 -1) 📝 `pkg/actions/interactive/states/ssh-key-manager.go` (+1 -1) 📝 `pkg/actions/interactive/states/ssh-key-options.go` (+1 -1) ➕ `pkg/actions/migrate.go` (+183 -0) 📝 `pkg/actions/remove-machine.go` (+1 -1) 📝 `pkg/actions/reset.go` (+2 -1) 📝 `pkg/actions/setup.go` (+105 -53) 📝 `pkg/actions/upload.go` (+3 -2) ➖ `pkg/dto/main.go` (+0 -82) 📝 `pkg/retrieval/data.go` (+6 -4) 📝 `pkg/retrieval/data_test.go` (+47 -22) ➕ `pkg/retrieval/deps.go` (+6 -0) 📝 `pkg/retrieval/machines.go` (+5 -4) 📝 `pkg/retrieval/machines_test.go` (+4 -1) _...and 9 more files_ </details> ### 📄 Description ## Summary This PR adds post-quantum cryptography support using **ML-DSA-65** (FIPS 204) for digital signatures and **ML-KEM-768** (FIPS 203) for key encapsulation, while preserving full backward compatibility with existing ECDSA/ECDH-ES users. ## Key Changes ### Cryptographic Algorithm Addition - **Signing**: ML-DSA-65 via `filippo.io/mldsa` (new default for fresh setups) - **Key Encapsulation**: ML-KEM-768 via Go stdlib `crypto/mlkem` (replaces ECDH/JWE for PQ users) - **Symmetric Encryption**: AES-256-GCM retained for both paths (already quantum-resistant) - Legacy ECDSA/ECDH-ES path kept intact; `lestrrat-go/jwx/v2` is still a dependency ### Key Format Auto-Detection (`pkg/utils/keyformat.go`) - New `DetectKeyFormat()` inspects the PEM block type in `~/.ssh-sync/keypair` - `"EC PRIVATE KEY"` → `FormatLegacyEC` - `"SSHSYNC PQ MASTER SEED"` → `FormatPostQuantum` - All format-sensitive operations (`Encrypt`, `Decrypt`, `GetToken`) branch on this at runtime ### Key Storage Format (PQ path) - Single 64-byte random master seed stored as `"SSHSYNC PQ MASTER SEED"` PEM block in `~/.ssh-sync/keypair` - ML-DSA-65 signing key + ML-KEM-768 decapsulation key both derived on demand from the seed via HKDF - `keypair.pub` contains the ML-DSA-65 public key (for server identity/JWT verification) - ML-KEM-768 encapsulation key is sent separately to the server during machine setup (stored server-side for key exchange relay) ### Key Generation (`pkg/actions/setup.go`) - `generateKey()` generates a 64-byte random master seed and writes a `"SSHSYNC PQ MASTER SEED"` PEM block - Both ML-DSA-65 and ML-KEM-768 keypairs are derived from the seed via HKDF on demand — only the seed is persisted - `generateKeyClassic()` (legacy ECDSA P-256) unchanged - `--classic` flag routes to the old path; default is now PQ ### Key Derivation (`pkg/utils/pqseed.go`) - `DeriveMLDSAKey(seed)` — HKDF-SHA256 with label `"ssh-sync-mldsa-v1"` → ML-DSA-65 keypair - `DeriveMLKEMKey(seed)` — HKDF-SHA256 with label `"ssh-sync-mlkem768-v1"` → ML-KEM-768 keypair ### Key Retrieval (`pkg/utils/keyretrieval.go`) - `RetrieveSigningKey()` — derives ML-DSA-65 private key from master seed - `RetrieveDecapsulationKey()` / `RetrieveEncapsulationKey()` — derives ML-KEM-768 keys from master seed - `BuildPQPublicKeys()` — returns ML-DSA-65 public key PEM + ML-KEM-768 encapsulation key PEM as separate fields (sent to server during existing-machine setup) - Legacy `RetrievePrivateKey()` / `RetrievePublicKey()` (JWK) unchanged ### JWT Token Generation (`pkg/utils/tokengen.go`) - `getTokenPQ()` — manual JWT construction signed with ML-DSA-65; uses `"alg": "MLDSA"` header - `getTokenLegacy()` — existing ES512 path unchanged - `GetToken()` auto-detects format and dispatches ### Encryption/Decryption (`pkg/utils/encrypt.go`, `pkg/utils/decrypt.go`) - `EncryptMLKEM` / `DecryptMLKEM` — ML-KEM-768 encapsulation → HKDF → AES-256-GCM - Ciphertext format: `[1088B ML-KEM ciphertext][12B nonce][AES-GCM ciphertext+tag]` - `EncryptWithPQPublicKey` — encrypts for a remote machine's ML-KEM encapsulation key (challenge-response) - `EncryptWithECPublicKey` — legacy path unchanged - `Encrypt` / `Decrypt` auto-detect format ### Challenge-Response (`pkg/actions/challenge-response.go`) - Now handles both PQ and legacy EC: if the server response includes an `EncapsulationKey`, uses `EncryptWithPQPublicKey`; otherwise falls back to `EncryptWithECPublicKey` ### Migration Command (`pkg/actions/migrate.go`) — new - New `migrate` command for existing EC users to upgrade to PQ in-place: 1. Decrypt master key with current EC keypair 2. Obtain a JWT signed with the old key (before overwriting) 3. Back up existing key files 4. Generate new PQ keypair 5. Re-encrypt master key with ML-KEM-768 6. Upload new public key to server (authenticated with the pre-obtained old-key token) 7. Clean up backups on success; rollback on failure - SSH keys stored on the server are unaffected (already AES-256-GCM encrypted) ### Dependencies - **Added**: `filippo.io/mldsa` (ML-DSA-65), `github.com/therealpaulgg/ssh-sync-common`, `golang.org/x/crypto` (promoted to direct) - **Retained**: `github.com/lestrrat-go/jwx/v2` (still used for legacy EC token signing and JWE encryption/decryption) - ML-KEM-768 uses Go stdlib `crypto/mlkem` (Go 1.25+) - Updated Go version to 1.25.0 ### Backward Compatibility - Existing EC users: all commands continue to work unchanged via format auto-detection - `--classic` flag available on setup to explicitly generate a legacy EC keypair - `migrate` command provides an explicit upgrade path from EC to PQ --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/ssh-sync#81
No description provided.