Commit a6092e5
Migrate from ECDSA to post-quantum cryptography (ML-DSA-65 + ML-KEM-768) (#77)
* feat: replace classical cryptography with post-quantum algorithms (ML-KEM-768 + ML-DSA-65)
Replace all quantum-vulnerable cryptographic operations with NIST-standardized
post-quantum algorithms to protect against future quantum computer attacks:
- Key generation: ECDSA P-256 → ML-DSA-65 (FIPS 204) for signing + ML-KEM-768
(FIPS 203) for key encapsulation
- JWT signing: ES512 (ECDSA+SHA-512) → ML-DSA-65 with custom JWT implementation
- Key exchange/encryption: ECDH-ES+A256KW (JWE) → ML-KEM-768 encapsulation +
AES-256-GCM
- AES-256-GCM symmetric encryption: unchanged (already quantum-resistant)
Dependencies:
- Add github.com/cloudflare/circl for ML-DSA-65 digital signatures
- Use Go 1.24 stdlib crypto/mlkem for ML-KEM-768 key encapsulation
- Remove github.com/lestrrat-go/jwx/v2 (no longer needed)
- Bump minimum Go version to 1.24.0
Key format changes:
- keypair/keypair.pub now use PEM-encoded ML-DSA-65 + ML-KEM-768 keys
- master_key uses ML-KEM ciphertext + AES-GCM encrypted format
- JWT tokens use "MLDSA65" algorithm identifier
Closes #57
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* feat: add backward-compatible migration path for legacy EC keys
Restore lestrrat-go/jwx dependency and implement dual-path crypto that
auto-detects whether the user has legacy ECDSA/ECDH-ES keys or new
post-quantum ML-DSA-65/ML-KEM-768 keys:
- Add keyformat.go with PEM block type detection (EC PRIVATE KEY vs
MLDSA65 PRIVATE KEY) to determine format at runtime
- All crypto operations (encrypt, decrypt, JWT signing, master key
retrieval) now branch based on detected key format
- Legacy users continue using ES512 JWTs + ECDH-ES JWE unchanged
- New setups generate post-quantum keys and use ML-DSA-65 + ML-KEM-768
- EncryptWithPublicKey auto-detects server key format (EC PEM vs ML-KEM
PEM) for cross-format challenge-response compatibility
- RetrieveMasterKey handles both JWE and ML-KEM encrypted master keys
This ensures existing users are not broken while new users get
quantum-resistant cryptography by default.
Closes #57
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* feat: add `migrate` command for upgrading keys to post-quantum crypto
Add a new CLI command `ssh-sync migrate` that upgrades existing users
from classical ECDSA/ECDH-ES keys to post-quantum ML-DSA-65/ML-KEM-768.
Migration flow:
1. Decrypt master key using the old EC keypair
2. Obtain a JWT signed with the old EC key (for server auth)
3. Back up all key files (keypair, keypair.pub, master_key)
4. Generate new ML-DSA-65 + ML-KEM-768 keypairs
5. Re-encrypt master key with new ML-KEM-768 encapsulation key
6. Upload new public key to server via PUT /api/v1/machines/key
7. Clean up backups on success
Safety features:
- Detects if keys are already PQ and skips migration
- Creates .bak files before modifying anything
- Full rollback on any failure (restores backups)
- Pre-obtains auth token before key rotation (server still has old
public key at upload time)
- User confirmation prompt before proceeding
Works with the server-side PUT /api/v1/machines/key endpoint from
ssh-sync-server PR #37.
Closes #57
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* feat: unify PQ keypairs into single master seed with HKDF derivation
Instead of storing two separate PQ keypairs (ML-DSA-65 + ML-KEM-768),
derive both deterministically from a single 64-byte master seed using
HKDF-SHA256 with domain separation. This restores the original single-
keypair mental model.
Key changes:
- Private key file: single PEM block "SSHSYNC PQ MASTER SEED" (64 bytes)
- keypair.pub (server upload): ML-DSA-65 only (server needs only this)
- WebSocket PublicKeyDto: both ML-DSA + ML-KEM (for client-to-client)
- Backward compat: supports 3 private key formats (EC, separate PQ, seed)
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* feat: add --classic flag to setup for optional EC key generation
Users can now opt into classical ECDSA P-256 / ECDH-ES cryptography
during setup with `ssh-sync setup --classic`. Post-quantum (ML-DSA-65 +
ML-KEM-768) remains the default. The existing auto-detection in
encrypt/decrypt/tokengen handles both formats transparently at runtime.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* fix: match generateKeyClassic signature to original generateKey
Restore the original (*ecdsa.PrivateKey, *ecdsa.PublicKey, error) return
signature and keep the function body identical to the main branch version.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* refactor: remove dead legacy PQ PEM format support
The intermediate format (separate MLDSA65/MLKEM768 PEM blocks) was never
released. Only two formats exist: EC (classic) and SSHSYNC PQ MASTER
SEED. Remove fallback paths for the unreleased format.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* refactor: separate signing and encapsulation keys in PublicKeyDto
PublicKey should only carry the signing/identity key (EC or ML-DSA).
Add a dedicated EncapsulationKey field for the ML-KEM encapsulation key
so the server can store and relay them independently.
- Add EncapsulationKey field to PublicKeyDto and ChallengeSuccessEncryptedKeyDto
- Replace BuildFullPublicKeyPEM with BuildPQPublicKeys (returns separate PEMs)
- Replace EncryptWithPublicKey with EncryptWithPQPublicKey and EncryptWithECPublicKey
- Remove unused DetectPEMKeyFormat / DetectPublicKeyFormat
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* start using shared library for my sanity
* remove go work
* refactor: replace ML-DSA + ML-KEM with hybrid ECDH P-256 + ML-KEM-768
ML-DSA has no Go stdlib implementation yet (expected Go 1.27).
Drop circl dependency and replace the pure PQ scheme with a hybrid KEM
that combines ECDH P-256 and ML-KEM-768. This protects the master key
against "store now, decrypt later" quantum attacks while keeping
production-ready ECDSA for authentication.
New default format (FormatHybrid):
- Auth: ECDSA P-256 JWT (ES256), derived from hybrid seed
- Key encapsulation: ephemeral ECDH + ML-KEM-768 → HKDF → AES-256-GCM
- Ciphertext: [65B eph EC pub][1088B ML-KEM ct][12B nonce][AES-GCM ct]
- Seed storage: single "SSHSYNC HYBRID SEED" PEM block (64 bytes)
- Public key file: "PUBLIC KEY" (PKIX EC) + "MLKEM768 ENCAPSULATION KEY"
--classic flag: unchanged pure-EC mode (ECDH-ES+A256KW, ECDSA JWT)
FormatLegacyEC path: unchanged for existing users
Key changes:
- pqseed.go → hybridseed.go (DeriveHybridKeys: EC + ML-KEM from HKDF)
- FormatPostQuantum → FormatHybrid
- EncryptHybrid/DecryptHybrid: ECDH + ML-KEM combined via HKDF
- getTokenPQ (ML-DSA) → getTokenHybrid (ECDSA ES256)
- Remove all ML-DSA code and circl dependency
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* fix: convert *ecdsa.PublicKey to *ecdh.PublicKey in EncryptWithHybridPublicKey
x509.ParsePKIXPublicKey returns *ecdsa.PublicKey for P-256 keys, not
*ecdh.PublicKey. Use ecdsa.PublicKey.ECDH() to convert before passing
to the hybrid KEM encrypt function.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* refactor: replace hybrid ECDH+ML-KEM with full ML-DSA-65 + ML-KEM-768 PQ scheme
Drop the hybrid ECDH P-256 + ML-KEM-768 approach in favor of a full
post-quantum scheme using ML-DSA-65 (filippo.io/mldsa) for digital
signatures and ML-KEM-768 (crypto/mlkem stdlib) for key encapsulation.
Key changes:
- Rename hybridseed.go → pqseed.go: DerivePQKeys returns ML-DSA-65
private key + ML-KEM-768 decapsulation key from master seed
- keyformat.go: FormatHybrid → FormatPostQuantum, PEM type
"SSHSYNC PQ MASTER SEED"
- encrypt.go/decrypt.go: ML-KEM-768 only (no ECDH), simplified
ciphertext format [1088B ML-KEM ct][12B nonce][AES-GCM ct+tag]
- tokengen.go: JWT signed with ML-DSA-65 (custom "MLDSA65" alg header)
instead of ECDSA ES256
- keyretrieval.go: RetrieveSigningKey (ML-DSA-65), BuildPQPublicKeys
returns ML-DSA-65 pub + ML-KEM encapsulation key
- setup.go: generateKey writes ML-DSA-65 + ML-KEM-768 public keys
- challenge-response.go: uses EncryptWithPQPublicKey (ML-KEM only)
- go.mod: add filippo.io/mldsa, remove ECDH dependencies
Note: go.sum not updated (requires network access). Run `go mod tidy`
to resolve filippo.io/mldsa version.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* get package actually working
* fix: align with filippo.io/mldsa actual API
The mldsa package uses a single PrivateKey/PublicKey type with
parameter sets (MLDSA65()) rather than suffixed types (PrivateKey65).
- NewPrivateKey(mldsa.MLDSA65(), seed) instead of NewPrivateKey65(seed)
- mldsa.PrivateKeySize instead of mldsa.SeedSize65
- mldsa.Verify(pk, msg, sig, nil) instead of mldsa.Verify65(pk, msg, sig)
- *mldsa.PrivateKey / *mldsa.PublicKey instead of *mldsa.PrivateKey65 etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* no more hybrid crypto scheme, do not save MLKEM768
* simplify master seed
* pr comments
* test updates
* Remove keypair.pub file for post-quantum keys
Since ML-DSA-65 and ML-KEM-768 public keys are deterministically derived
from the master seed via HKDF, there is no need to persist them in a
keypair.pub file. generateKey() now only writes the seed to keypair.
Public keys are built on demand via BuildPQPublicKeys() when needed for
server upload (new account setup and migration).
Also remove keypair.pub backup/restore from the migration flow.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* Fix: only send ML-DSA signing key to server, not encapsulation key
The server only needs the ML-DSA public key for JWT verification.
The ML-KEM encapsulation key is only relevant in the existing-account
setup websocket flow where Machine A needs it to encrypt the master key.
newAccountSetup() and uploadMigratedKey() were incorrectly concatenating
both PEM blocks into the multipart upload.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* Split BuildPQPublicKeys into separate MLDSA and MLKEM functions
BuildMLDSAPublicKeyPEM() returns just the signing public key PEM.
BuildMLKEMEncapsulationKeyPEM() returns just the encapsulation key PEM.
https://claude.ai/code/session_017C6wHEG2Czc1NrNWWb4aCL
* remove obvious comments
* remove "legacy" language
* remove comments...
* leave acceptable "step" comments
* improve comments
* simplify signature of generateKeyClassic
* simplify code, add new util
* remove unnecessary mocks
* simplify aes encryption, code deduplication
* add salt
* add comment clarifying salt length
* remove HKDF for ML-KEM crypto functions (unnecessary)
* move files around
* remove hkdf, use seed
* tidy
* deslopt
* error handling, write on log
* replace const
* delete bad verify method and tests
* token testing - use draft ietf ML-DSA spec
* idk why this got changed
* consistency i guess
* nitpicky, rename to ML-DSA
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Gellai <Paul.Gellai+cvna@carvana.com>1 parent 012687a commit a6092e5
File tree
30 files changed
+1188
-329
lines changed- pkg
- actions
- interactive/states
- dto
- retrieval
- utils
30 files changed
+1188
-329
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
4 | | - | |
5 | | - | |
| 3 | + | |
6 | 4 | | |
7 | 5 | | |
| 6 | + | |
8 | 7 | | |
9 | 8 | | |
10 | 9 | | |
11 | | - | |
12 | | - | |
| 10 | + | |
| 11 | + | |
13 | 12 | | |
14 | 13 | | |
15 | 14 | | |
| 15 | + | |
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
51 | 51 | | |
52 | | - | |
53 | | - | |
54 | | - | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
1 | 3 | | |
2 | 4 | | |
3 | 5 | | |
| |||
25 | 27 | | |
26 | 28 | | |
27 | 29 | | |
28 | | - | |
29 | | - | |
| 30 | + | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | | - | |
33 | | - | |
| 34 | + | |
| 35 | + | |
34 | 36 | | |
35 | 37 | | |
36 | 38 | | |
| |||
82 | 84 | | |
83 | 85 | | |
84 | 86 | | |
| 87 | + | |
| 88 | + | |
85 | 89 | | |
86 | 90 | | |
87 | 91 | | |
88 | 92 | | |
89 | | - | |
90 | | - | |
| 93 | + | |
| 94 | + | |
91 | 95 | | |
92 | 96 | | |
93 | | - | |
94 | | - | |
95 | | - | |
| 97 | + | |
| 98 | + | |
96 | 99 | | |
97 | 100 | | |
98 | | - | |
99 | | - | |
100 | | - | |
101 | | - | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
102 | 105 | | |
103 | 106 | | |
104 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
25 | 32 | | |
26 | 33 | | |
27 | 34 | | |
| |||
63 | 70 | | |
64 | 71 | | |
65 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
66 | 78 | | |
67 | 79 | | |
68 | 80 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
54 | 55 | | |
55 | 56 | | |
56 | 57 | | |
57 | | - | |
| 58 | + | |
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
61 | 62 | | |
62 | | - | |
| 63 | + | |
63 | 64 | | |
64 | 65 | | |
65 | 66 | | |
66 | 67 | | |
67 | 68 | | |
68 | 69 | | |
69 | 70 | | |
70 | | - | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
71 | 77 | | |
72 | 78 | | |
73 | 79 | | |
74 | | - | |
| 80 | + | |
75 | 81 | | |
76 | 82 | | |
77 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| |||
0 commit comments