Skip to main content

atlas_ecdh_bridge/
lib.rs

1// Copyright (c) 2024-2026 Atlas Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! # atlas-ecdh-bridge
5//!
6//! **Derive deterministic Ed25519 signing keys from WebAuthn/Passkey P-256 ECDH —
7//! zero persistent secrets, hardware-bound identity.**
8//!
9//! ## The Problem
10//!
11//! Passkeys (WebAuthn/FIDO2) use NIST P-256 keys locked inside hardware security
12//! modules — Android StrongBox, iOS Secure Enclave, Windows Hello, YubiKeys.
13//! You **cannot** export the private key, and you **cannot** sign with Ed25519.
14//!
15//! Meanwhile, most blockchains (Solana, Sui, Aptos, Stellar, NEAR, Cosmos, etc.)
16//! require **Ed25519** signatures. The curves are mathematically incompatible.
17//!
18//! ## The Solution
19//!
20//! This crate bridges the gap using **ECDH key agreement** — a standard operation
21//! that passkey hardware already supports:
22//!
23//! ```text
24//! passkey_private × FIXED_POINT → 32-byte shared secret (inside TEE)
25//!                                        ↓
26//!              HKDF(secret, "solana:ed25519:v1") → Ed25519 seed → sign → zeroize
27//! ```
28//!
29//! **One passkey → deterministic Ed25519 keys for every chain → zero secrets stored.**
30//!
31//! ## Security Properties
32//!
33//! - **No persistent secrets** — Ed25519 key material exists in RAM only during
34//!   [`sign()`], then is zeroized via the `zeroize` crate
35//! - **Deterministic** — same passkey × same fixed point = same addresses, every time
36//! - **Biometric-gated** — ECDH requires user verification (fingerprint, face, PIN)
37//! - **Hardware-bound** — the passkey private key never leaves the secure element
38//! - **Domain-separated** — each chain gets an independent key via HKDF with unique salt
39//! - **No seed phrase** — the hardware IS the identity
40//! - **Auditable** — the fixed point is derived from a public domain string
41
42use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
43use hkdf::Hkdf;
44use p256::elliptic_curve::ops::{MulByGenerator, Reduce};
45use p256::elliptic_curve::sec1::ToEncodedPoint;
46use sha2::{Digest, Sha256};
47use zeroize::Zeroizing;
48
49// ─────────────────────────────────────────────────────────────────────────────
50// Domain string & chain salts
51// ─────────────────────────────────────────────────────────────────────────────
52
53/// Domain string for fixed-point scalar derivation.
54/// SHA-256(DOMAIN_STRING) mod n → scalar → scalar × G = fixed point.
55const DOMAIN_STRING: &[u8] = b"atlas:ecdh:p256:ed25519:derivation:v1";
56
57/// HKDF info field used for all chain derivations.
58const HKDF_INFO: &[u8] = b"ed25519-signing-key";
59
60// ─────────────────────────────────────────────────────────────────────────────
61// Chain enum
62// ─────────────────────────────────────────────────────────────────────────────
63
64/// Supported blockchain chains for Ed25519 key derivation.
65///
66/// Each variant maps to a unique HKDF salt, ensuring cryptographically
67/// independent Ed25519 keys from the same ECDH shared secret.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub enum Chain {
70    Solana,
71    Sui,
72    Aptos,
73    Sei,
74    Stellar,
75    Near,
76    Cosmos,
77    Polkadot,
78    Cardano,
79    Ton,
80    /// Custom chain with a user-defined salt string.
81    /// Use format: `"your-app:your-chain:ed25519:v1"`
82    Custom(String),
83}
84
85impl Chain {
86    /// Get the HKDF salt bytes for this chain.
87    pub fn salt(&self) -> Vec<u8> {
88        match self {
89            Chain::Solana => b"atlas:ecdh:solana:ed25519:v1".to_vec(),
90            Chain::Sui => b"atlas:ecdh:sui:ed25519:v1".to_vec(),
91            Chain::Aptos => b"atlas:ecdh:aptos:ed25519:v1".to_vec(),
92            Chain::Sei => b"atlas:ecdh:sei:ed25519:v1".to_vec(),
93            Chain::Stellar => b"atlas:ecdh:stellar:ed25519:v1".to_vec(),
94            Chain::Near => b"atlas:ecdh:near:ed25519:v1".to_vec(),
95            Chain::Cosmos => b"atlas:ecdh:cosmos:ed25519:v1".to_vec(),
96            Chain::Polkadot => b"atlas:ecdh:polkadot:ed25519:v1".to_vec(),
97            Chain::Cardano => b"atlas:ecdh:cardano:ed25519:v1".to_vec(),
98            Chain::Ton => b"atlas:ecdh:ton:ed25519:v1".to_vec(),
99            Chain::Custom(s) => s.as_bytes().to_vec(),
100        }
101    }
102
103    /// Display name for this chain.
104    pub fn name(&self) -> &str {
105        match self {
106            Chain::Solana => "Solana",
107            Chain::Sui => "Sui",
108            Chain::Aptos => "Aptos",
109            Chain::Sei => "Sei",
110            Chain::Stellar => "Stellar",
111            Chain::Near => "NEAR",
112            Chain::Cosmos => "Cosmos",
113            Chain::Polkadot => "Polkadot",
114            Chain::Cardano => "Cardano",
115            Chain::Ton => "TON",
116            Chain::Custom(s) => s.as_str(),
117        }
118    }
119
120    /// All built-in chain variants (excludes Custom).
121    pub fn all_builtins() -> &'static [Chain] {
122        &[
123            Chain::Solana,
124            Chain::Sui,
125            Chain::Aptos,
126            Chain::Sei,
127            Chain::Stellar,
128            Chain::Near,
129            Chain::Cosmos,
130            Chain::Polkadot,
131            Chain::Cardano,
132            Chain::Ton,
133        ]
134    }
135}
136
137impl std::fmt::Display for Chain {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(f, "{}", self.name())
140    }
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// Fixed Point computation
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Compute the fixed P-256 public key used for ECDH derivation.
148///
149/// Returns **65 bytes**: `[0x04 || X(32) || Y(32)]` (uncompressed SEC1 format),
150/// suitable for Android `KeyAgreement`, iOS `SecKey`, and WebAuthn.
151///
152/// The point is derived as:
153/// ```text
154/// scalar = SHA-256("atlas:ecdh:p256:ed25519:derivation:v1") mod n
155/// FIXED_POINT = scalar × G  (P-256 generator)
156/// ```
157///
158/// This is a well-known, auditable constant — **not** a secret.
159///
160/// # Example
161/// ```
162/// let point = atlas_ecdh_bridge::fixed_point_uncompressed();
163/// assert_eq!(point.len(), 65);
164/// assert_eq!(point[0], 0x04);
165/// ```
166pub fn fixed_point_uncompressed() -> Vec<u8> {
167    let scalar = domain_scalar();
168    let point = p256::ProjectivePoint::mul_by_generator(&scalar);
169    let affine = p256::AffinePoint::from(point);
170    affine.to_encoded_point(false).as_bytes().to_vec()
171}
172
173/// Return the fixed point as raw `X || Y` (64 bytes, no `0x04` prefix).
174///
175/// Convenience for platforms that take separate X/Y coordinates, e.g.
176/// Kotlin's `ECPoint(BigInteger(x), BigInteger(y))`.
177///
178/// # Example
179/// ```
180/// let xy = atlas_ecdh_bridge::fixed_point_xy();
181/// assert_eq!(xy.len(), 64);
182/// ```
183pub fn fixed_point_xy() -> Vec<u8> {
184    fixed_point_uncompressed()[1..].to_vec()
185}
186
187/// Return the fixed point in compressed SEC1 format (33 bytes: `02/03 || X`).
188pub fn fixed_point_compressed() -> Vec<u8> {
189    let scalar = domain_scalar();
190    let point = p256::ProjectivePoint::mul_by_generator(&scalar);
191    let affine = p256::AffinePoint::from(point);
192    affine.to_encoded_point(true).as_bytes().to_vec()
193}
194
195/// Internal: derive the P-256 scalar from the domain string.
196fn domain_scalar() -> p256::Scalar {
197    let hash = Sha256::digest(DOMAIN_STRING);
198    <p256::Scalar as Reduce<p256::U256>>::reduce_bytes(&hash)
199}
200
201// ─────────────────────────────────────────────────────────────────────────────
202// HKDF-based Ed25519 key derivation
203// ─────────────────────────────────────────────────────────────────────────────
204
205/// Derive a 32-byte Ed25519 seed from the ECDH shared secret + chain salt.
206///
207/// Uses HKDF-SHA256 with:
208/// - IKM = 32-byte ECDH shared secret
209/// - salt = chain-specific domain string
210/// - info = "ed25519-signing-key"
211fn derive_seed(ecdh_secret: &[u8], chain: &Chain) -> Result<Zeroizing<[u8; 32]>, String> {
212    if ecdh_secret.len() != 32 {
213        return Err(format!(
214            "ECDH secret must be exactly 32 bytes, got {}",
215            ecdh_secret.len()
216        ));
217    }
218
219    let salt = chain.salt();
220    let hk = Hkdf::<Sha256>::new(Some(&salt), ecdh_secret);
221    let mut okm = Zeroizing::new([0u8; 32]);
222    hk.expand(HKDF_INFO, okm.as_mut())
223        .map_err(|e| format!("HKDF expand failed: {}", e))?;
224    Ok(okm)
225}
226
227// ─────────────────────────────────────────────────────────────────────────────
228// Public API — Key derivation
229// ─────────────────────────────────────────────────────────────────────────────
230
231/// Derive the raw 32-byte Ed25519 public key for a given chain.
232///
233/// # Example
234/// ```
235/// use atlas_ecdh_bridge::Chain;
236/// let secret = [0xAB_u8; 32];
237/// let pubkey = atlas_ecdh_bridge::derive_public_key(&secret, &Chain::Solana).unwrap();
238/// assert_eq!(pubkey.len(), 32);
239/// ```
240pub fn derive_public_key(ecdh_secret: &[u8], chain: &Chain) -> Result<Vec<u8>, String> {
241    let seed = derive_seed(ecdh_secret, chain)?;
242    let signing_key = SigningKey::from_bytes(&seed);
243    let verifying_key: VerifyingKey = (&signing_key).into();
244    Ok(verifying_key.to_bytes().to_vec())
245}
246
247/// Derive the Ed25519 public key as a **base58** string (standard Solana address format).
248///
249/// # Example
250/// ```
251/// use atlas_ecdh_bridge::Chain;
252/// let secret = [0xAB_u8; 32];
253/// let addr = atlas_ecdh_bridge::derive_public_key_base58(&secret, &Chain::Solana).unwrap();
254/// assert!(addr.len() >= 32 && addr.len() <= 44);
255/// ```
256pub fn derive_public_key_base58(ecdh_secret: &[u8], chain: &Chain) -> Result<String, String> {
257    let pubkey = derive_public_key(ecdh_secret, chain)?;
258    Ok(bs58::encode(&pubkey).into_string())
259}
260
261/// Derive the Ed25519 public key as a **hex** string (standard Sui/Aptos address format).
262pub fn derive_public_key_hex(ecdh_secret: &[u8], chain: &Chain) -> Result<String, String> {
263    let pubkey = derive_public_key(ecdh_secret, chain)?;
264    Ok(hex::encode(&pubkey))
265}
266
267/// Derive Ed25519 public keys for **all 10 built-in chains** at once.
268///
269/// Returns a `Vec` of `(Chain, base58_address)` pairs.
270///
271/// # Example
272/// ```
273/// let secret = [0xAB_u8; 32];
274/// let keys = atlas_ecdh_bridge::derive_all_builtin_keys(&secret).unwrap();
275/// assert_eq!(keys.len(), 10);
276/// ```
277pub fn derive_all_builtin_keys(
278    ecdh_secret: &[u8],
279) -> Result<Vec<(Chain, String)>, String> {
280    Chain::all_builtins()
281        .iter()
282        .map(|chain| {
283            let addr = derive_public_key_base58(ecdh_secret, chain)?;
284            Ok((chain.clone(), addr))
285        })
286        .collect()
287}
288
289// ─────────────────────────────────────────────────────────────────────────────
290// Public API — Signing & Verification
291// ─────────────────────────────────────────────────────────────────────────────
292
293/// Sign a message with the ECDH-derived Ed25519 key for a given chain.
294///
295/// **Security:** The Ed25519 private key exists ONLY during this function call,
296/// then is zeroized. No key material persists after return.
297///
298/// Returns a 64-byte Ed25519 signature.
299///
300/// # Example
301/// ```
302/// use atlas_ecdh_bridge::Chain;
303/// let secret = [0xAB_u8; 32];
304/// let sig = atlas_ecdh_bridge::sign(&secret, b"hello", &Chain::Solana).unwrap();
305/// assert_eq!(sig.len(), 64);
306/// ```
307pub fn sign(ecdh_secret: &[u8], message: &[u8], chain: &Chain) -> Result<Vec<u8>, String> {
308    let seed = derive_seed(ecdh_secret, chain)?;
309    let signing_key = SigningKey::from_bytes(&seed);
310    let signature = signing_key.sign(message);
311    // seed is Zeroizing — auto-zeroed on drop
312    Ok(signature.to_bytes().to_vec())
313}
314
315/// Verify an Ed25519 signature against a public key.
316///
317/// `pubkey` must be 32 bytes (raw Ed25519 public key).
318/// `signature` must be 64 bytes.
319pub fn verify(pubkey: &[u8], message: &[u8], signature: &[u8]) -> Result<(), String> {
320    if pubkey.len() != 32 {
321        return Err(format!("Public key must be 32 bytes, got {}", pubkey.len()));
322    }
323    if signature.len() != 64 {
324        return Err(format!("Signature must be 64 bytes, got {}", signature.len()));
325    }
326
327    let vk_bytes: [u8; 32] = pubkey.try_into().map_err(|_| "Invalid pubkey length")?;
328    let verifying_key =
329        VerifyingKey::from_bytes(&vk_bytes).map_err(|e| format!("Invalid public key: {}", e))?;
330
331    let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| "Invalid signature length")?;
332    let sig = Signature::from_bytes(&sig_bytes);
333
334    verifying_key
335        .verify(message, &sig)
336        .map_err(|e| format!("Signature verification failed: {}", e))
337}
338
339// ─────────────────────────────────────────────────────────────────────────────
340// Utility
341// ─────────────────────────────────────────────────────────────────────────────
342
343/// Print the fixed point in all formats — useful for embedding in platform code.
344pub fn print_fixed_point_info() {
345    let uncompressed = fixed_point_uncompressed();
346    let xy = fixed_point_xy();
347    let compressed = fixed_point_compressed();
348
349    println!("═══ atlas-ecdh-bridge Fixed Point ═══");
350    println!();
351    println!("Domain: {}", std::str::from_utf8(DOMAIN_STRING).unwrap());
352    println!();
353    println!(
354        "Uncompressed (65 bytes): {}",
355        hex::encode(&uncompressed)
356    );
357    println!("X (32 bytes): {}", hex::encode(&xy[..32]));
358    println!("Y (32 bytes): {}", hex::encode(&xy[32..]));
359    println!(
360        "Compressed (33 bytes):   {}",
361        hex::encode(&compressed)
362    );
363    println!();
364    println!("── Android (Kotlin) ──");
365    println!("val fixedPointXY = byteArrayOf(");
366    for (i, chunk) in xy.chunks(16).enumerate() {
367        let hex_str: Vec<String> = chunk.iter().map(|b| format!("0x{:02X}.toByte()", b)).collect();
368        let comma = if i < (xy.len() + 15) / 16 - 1 { "," } else { "" };
369        println!("    {}{}", hex_str.join(", "), comma);
370    }
371    println!(")");
372    println!();
373    println!("── iOS (Swift) ──");
374    println!(
375        "let fixedPoint = Data(hex: \"{}\")",
376        hex::encode(&uncompressed)
377    );
378    println!();
379    println!("── Web (JavaScript) ──");
380    println!(
381        "const fixedPoint = new Uint8Array([{}]);",
382        uncompressed
383            .iter()
384            .map(|b| format!("0x{:02X}", b))
385            .collect::<Vec<_>>()
386            .join(", ")
387    );
388}
389
390// ─────────────────────────────────────────────────────────────────────────────
391// Tests
392// ─────────────────────────────────────────────────────────────────────────────
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn fixed_point_is_valid_p256() {
400        let point = fixed_point_uncompressed();
401        assert_eq!(point.len(), 65);
402        assert_eq!(point[0], 0x04);
403
404        // Verify it's a valid P-256 point by parsing it
405        let encoded =
406            p256::EncodedPoint::from_bytes(&point).expect("Must be valid SEC1 encoding");
407        assert!(!encoded.is_identity());
408    }
409
410    #[test]
411    fn fixed_point_is_deterministic() {
412        assert_eq!(fixed_point_uncompressed(), fixed_point_uncompressed());
413    }
414
415    #[test]
416    fn fixed_point_xy_matches_uncompressed() {
417        let full = fixed_point_uncompressed();
418        let xy = fixed_point_xy();
419        assert_eq!(xy.len(), 64);
420        assert_eq!(&full[1..], &xy[..]);
421    }
422
423    #[test]
424    fn fixed_point_compressed_matches() {
425        let compressed = fixed_point_compressed();
426        assert_eq!(compressed.len(), 33);
427        assert!(compressed[0] == 0x02 || compressed[0] == 0x03);
428    }
429
430    #[test]
431    fn derive_pubkey_is_32_bytes() {
432        let secret = [0xAB_u8; 32];
433        let pubkey = derive_public_key(&secret, &Chain::Solana).unwrap();
434        assert_eq!(pubkey.len(), 32);
435    }
436
437    #[test]
438    fn derive_pubkey_is_deterministic() {
439        let secret = [0xAB_u8; 32];
440        let pk1 = derive_public_key(&secret, &Chain::Solana).unwrap();
441        let pk2 = derive_public_key(&secret, &Chain::Solana).unwrap();
442        assert_eq!(pk1, pk2);
443    }
444
445    #[test]
446    fn different_chains_produce_different_keys() {
447        let secret = [0xCD_u8; 32];
448        let sol = derive_public_key(&secret, &Chain::Solana).unwrap();
449        let sui = derive_public_key(&secret, &Chain::Sui).unwrap();
450        let apt = derive_public_key(&secret, &Chain::Aptos).unwrap();
451        let sei = derive_public_key(&secret, &Chain::Sei).unwrap();
452        assert_ne!(sol, sui);
453        assert_ne!(sol, apt);
454        assert_ne!(sol, sei);
455        assert_ne!(sui, apt);
456    }
457
458    #[test]
459    fn different_secrets_produce_different_keys() {
460        let pk1 = derive_public_key(&[0xAA_u8; 32], &Chain::Solana).unwrap();
461        let pk2 = derive_public_key(&[0xBB_u8; 32], &Chain::Solana).unwrap();
462        assert_ne!(pk1, pk2);
463    }
464
465    #[test]
466    fn base58_address_format() {
467        let secret = [0x42_u8; 32];
468        let addr = derive_public_key_base58(&secret, &Chain::Solana).unwrap();
469        assert!(addr.len() >= 32 && addr.len() <= 44);
470    }
471
472    #[test]
473    fn hex_address_format() {
474        let secret = [0x42_u8; 32];
475        let addr = derive_public_key_hex(&secret, &Chain::Sui).unwrap();
476        assert_eq!(addr.len(), 64); // 32 bytes = 64 hex chars
477    }
478
479    #[test]
480    fn custom_chain_works() {
481        let secret = [0xEE_u8; 32];
482        let custom = Chain::Custom("my-app:my-chain:ed25519:v1".into());
483        let pk = derive_public_key(&secret, &custom).unwrap();
484        assert_eq!(pk.len(), 32);
485
486        // Must differ from built-in chains
487        let sol = derive_public_key(&secret, &Chain::Solana).unwrap();
488        assert_ne!(pk, sol);
489    }
490
491    #[test]
492    fn derive_all_builtin() {
493        let secret = [0xAB_u8; 32];
494        let keys = derive_all_builtin_keys(&secret).unwrap();
495        assert_eq!(keys.len(), 10);
496
497        // All different
498        let addrs: Vec<&String> = keys.iter().map(|(_, a)| a).collect();
499        for i in 0..addrs.len() {
500            for j in (i + 1)..addrs.len() {
501                assert_ne!(addrs[i], addrs[j]);
502            }
503        }
504    }
505
506    #[test]
507    fn sign_verify_roundtrip() {
508        let secret = [0xEF_u8; 32];
509        let message = b"hello solana";
510
511        let sig = sign(&secret, message, &Chain::Solana).unwrap();
512        assert_eq!(sig.len(), 64);
513
514        let pubkey = derive_public_key(&secret, &Chain::Solana).unwrap();
515        verify(&pubkey, message, &sig).expect("Signature must verify");
516    }
517
518    #[test]
519    fn wrong_message_fails_verification() {
520        let secret = [0xEF_u8; 32];
521        let sig = sign(&secret, b"correct", &Chain::Solana).unwrap();
522        let pubkey = derive_public_key(&secret, &Chain::Solana).unwrap();
523        assert!(verify(&pubkey, b"wrong", &sig).is_err());
524    }
525
526    #[test]
527    fn wrong_chain_fails_verification() {
528        let secret = [0xEF_u8; 32];
529        let sig = sign(&secret, b"msg", &Chain::Solana).unwrap();
530        let sui_pk = derive_public_key(&secret, &Chain::Sui).unwrap();
531        assert!(verify(&sui_pk, b"msg", &sig).is_err());
532    }
533
534    #[test]
535    fn sign_verify_all_builtin_chains() {
536        let secret = [0xDD_u8; 32];
537        let msg = b"test all chains";
538
539        for chain in Chain::all_builtins() {
540            let sig = sign(&secret, msg, chain).unwrap();
541            let pk = derive_public_key(&secret, chain).unwrap();
542            verify(&pk, msg, &sig)
543                .unwrap_or_else(|e| panic!("Failed for {}: {}", chain.name(), e));
544        }
545    }
546
547    #[test]
548    fn verify_invalid_pubkey() {
549        assert!(verify(&[0u8; 16], b"msg", &[0u8; 64]).is_err());
550    }
551
552    #[test]
553    fn verify_invalid_signature_length() {
554        let secret = [0xAA_u8; 32];
555        let pk = derive_public_key(&secret, &Chain::Solana).unwrap();
556        assert!(verify(&pk, b"msg", &[0u8; 32]).is_err());
557    }
558
559    #[test]
560    fn invalid_secret_length_0() {
561        assert!(derive_public_key(&[], &Chain::Solana).is_err());
562    }
563
564    #[test]
565    fn invalid_secret_length_16() {
566        assert!(derive_public_key(&[0u8; 16], &Chain::Solana).is_err());
567    }
568
569    #[test]
570    fn invalid_secret_length_48() {
571        assert!(sign(&[0u8; 48], b"msg", &Chain::Solana).is_err());
572    }
573}