Merge ee3cfb6ec64ed3ce00be4dd6cdd8d43f30f3fd18 into d3390efc6db215cef35ba1d6d1f5e13277fe9597

This commit is contained in:
jonesmarvin8 2026-05-30 00:23:39 +00:00 committed by GitHub
commit 9e3ce2d071
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 1560 additions and 583 deletions

221
Cargo.lock generated
View File

@ -273,7 +273,7 @@ dependencies = [
"ark-std 0.4.0",
"blake2",
"derivative",
"digest",
"digest 0.10.7",
"sha2",
]
@ -293,7 +293,7 @@ dependencies = [
"ark-std 0.5.0",
"blake2",
"derivative",
"digest",
"digest 0.10.7",
"fnv",
"merlin",
"sha2",
@ -359,7 +359,7 @@ dependencies = [
"ark-serialize 0.4.2",
"ark-std 0.4.0",
"derivative",
"digest",
"digest 0.10.7",
"itertools 0.10.5",
"num-bigint 0.4.6",
"num-traits",
@ -379,7 +379,7 @@ dependencies = [
"ark-serialize 0.5.0",
"ark-std 0.5.0",
"arrayvec",
"digest",
"digest 0.10.7",
"educe",
"itertools 0.13.0",
"num-bigint 0.4.6",
@ -541,7 +541,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
dependencies = [
"ark-serialize-derive 0.4.2",
"ark-std 0.4.0",
"digest",
"digest 0.10.7",
"num-bigint 0.4.6",
]
@ -554,7 +554,7 @@ dependencies = [
"ark-serialize-derive 0.5.0",
"ark-std 0.5.0",
"arrayvec",
"digest",
"digest 0.10.7",
"num-bigint 0.4.6",
]
@ -1192,7 +1192,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@ -1622,6 +1622,12 @@ dependencies = [
"nssa_core",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cobs"
version = "0.3.0"
@ -1757,6 +1763,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-str"
version = "0.4.3"
@ -2023,17 +2035,22 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"getrandom 0.4.2",
"hybrid-array",
"rand_core 0.10.1",
]
[[package]]
name = "crypto_primitives_bench"
version = "0.1.0"
dependencies = [
"anyhow",
"criterion",
"key_protocol",
"nssa_core",
"rand 0.8.5",
"serde",
"serde_json",
]
[[package]]
@ -2045,6 +2062,15 @@ dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@ -2054,7 +2080,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"serde",
@ -2186,7 +2212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@ -2195,11 +2221,21 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
dependencies = [
"const-oid 0.10.2",
"zeroize",
]
[[package]]
name = "der-parser"
version = "10.0.0"
@ -2318,11 +2354,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
]
[[package]]
name = "directories"
version = "6.0.0"
@ -2416,7 +2462,7 @@ version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ac1e888d6830712d565b2f3a974be3200be9296bc1b03db8251a4cbf18a4a34"
dependencies = [
"digest",
"digest 0.10.7",
"futures",
"rand 0.8.5",
"reqwest",
@ -2448,13 +2494,13 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"der 0.7.10",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"serdect",
"signature",
"spki",
"spki 0.7.3",
]
[[package]]
@ -2463,7 +2509,7 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"pkcs8 0.10.2",
"serde",
"signature",
]
@ -2525,12 +2571,12 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array 0.14.7",
"group",
"pem-rfc7468",
"pkcs8",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1",
"serdect",
@ -2670,7 +2716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3436,7 +3482,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@ -3558,6 +3604,7 @@ version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1"
dependencies = [
"ctutils",
"typenum",
]
@ -4504,6 +4551,26 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "keccak"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
]
[[package]]
name = "kem"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
dependencies = [
"crypto-common 0.2.1",
"rand_core 0.10.1",
]
[[package]]
name = "key_protocol"
version = "0.1.0"
@ -4518,6 +4585,7 @@ dependencies = [
"hmac-sha512",
"itertools 0.14.0",
"k256",
"ml-kem",
"nssa",
"nssa_core",
"rand 0.8.5",
@ -6182,7 +6250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
dependencies = [
"byteorder",
"keccak",
"keccak 0.1.6",
"rand_core 0.6.4",
"zeroize",
]
@ -6245,6 +6313,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ml-kem"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
dependencies = [
"hybrid-array",
"kem",
"module-lattice",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sha3",
]
[[package]]
name = "module-lattice"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
dependencies = [
"ctutils",
"hybrid-array",
"num-traits",
]
[[package]]
name = "moka"
version = "0.12.15"
@ -6497,10 +6590,10 @@ dependencies = [
"ark-ec 0.4.2",
"ark-ff 0.4.2",
"ark-serialize 0.4.2",
"digest",
"digest 0.10.7",
"generic-array 0.14.7",
"hex",
"keccak",
"keccak 0.1.6",
"log",
"rand 0.8.5",
"zeroize",
@ -6590,7 +6683,7 @@ dependencies = [
"bytemuck",
"bytesize",
"chacha20",
"k256",
"ml-kem",
"risc0-zkvm",
"serde",
"serde_json",
@ -7142,9 +7235,9 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
"der 0.7.10",
"pkcs8 0.10.2",
"spki 0.7.3",
]
[[package]]
@ -7153,8 +7246,18 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.0",
"spki 0.8.0",
]
[[package]]
@ -7627,7 +7730,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -7664,9 +7767,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -8126,7 +8229,7 @@ dependencies = [
"anyhow",
"bytemuck",
"cfg-if",
"keccak",
"keccak 0.1.6",
"liblzma",
"paste",
"rayon",
@ -8309,7 +8412,7 @@ dependencies = [
"borsh",
"bytemuck",
"cfg-if",
"digest",
"digest 0.10.7",
"ff",
"hex",
"hex-literal 0.4.1",
@ -8348,7 +8451,7 @@ dependencies = [
"gdbstub_arch",
"gimli",
"hex",
"keccak",
"keccak 0.1.6",
"lazy-regex",
"num-bigint 0.4.6",
"num-traits",
@ -8466,16 +8569,16 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"pkcs8 0.10.2",
"rand_core 0.6.4",
"signature",
"spki",
"spki 0.7.3",
"subtle",
"zeroize",
]
@ -8585,7 +8688,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8643,7 +8746,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs 0.26.11",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -8791,9 +8894,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"der 0.7.10",
"generic-array 0.14.7",
"pkcs8",
"pkcs8 0.10.2",
"serdect",
"subtle",
"zeroize",
@ -9181,7 +9284,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@ -9192,7 +9295,17 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha3"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
dependencies = [
"digest 0.11.3",
"keccak 0.2.0",
]
[[package]]
@ -9226,7 +9339,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@ -9325,7 +9438,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
"der 0.7.10",
]
[[package]]
name = "spki"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [
"base64ct",
"der 0.8.0",
]
[[package]]
@ -9570,7 +9693,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -10986,7 +11109,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@ -157,6 +157,7 @@ k256 = { version = "0.13.3", features = [
"serde",
"pem",
] }
ml-kem = { version = "0.3", features = ["hazmat"] }
elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
actix-web = { version = "4.13.0", default-features = false, features = [
"macros",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -155,7 +155,7 @@ wallet account new private
# Output:
Generated new account with account_id Private/HacPU3hakLYzWtSqUPw6TUr8fqoMieVWovsUR6sJf7cL
With npk e6366f79d026c8bd64ae6b3d601f0506832ec682ab54897f205fffe64ec0d951
With vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
> [!Tip]
@ -231,19 +231,29 @@ wallet account new private-accounts-key
# Output:
Generated new private accounts key at path /1
With npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e
With vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
> [!Tip]
> Ignore the account ID here and use the `npk` and `vpk` values to send to a foreign private account.
> [!Important]
> The VPK is now a 1184-byte ML-KEM-768 encapsulation key — too large to copy-paste into a command.
> The recommended workflow is:
>
> **Recipient:** export both keys to a single file and send the file to the sender (e.g. as an email attachment):
> ```bash
> wallet account show-keys --account-id Private/<account-id> > recipient.keys
> # Send recipient.keys to the sender out-of-band
> ```
> The file contains two lines: the npk (hex) on line 1, the vpk (hex) on line 2.
>
> **Sender:** reference the received file with `--to-keys`:
### b. Send 3 tokens using the recipients npk and vpk
### b. Send 3 tokens using the recipients keys file
```bash
# The sender has received recipient.keys from the recipient out-of-band
wallet auth-transfer send \
--from Public/Ev1JprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPAWS \
--to-npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e \
--to-vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 \
--to-keys recipient.keys \
--amount 3
```
@ -270,18 +280,19 @@ wallet account new private-accounts-key
# Output:
Generated new private accounts key at path /2
With npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345
With vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c
With vpk <1184-byte ML-KEM-768 encapsulation key, hex-encoded>
```
Alice shares the `npk` and `vpk` values with Bob and Charlie out of band.
### b. Bob sends 10 tokens to Alice using identifier 1
Bob uses the received `alice.keys` file:
```bash
wallet auth-transfer send \
--from Public/BobXqJprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPA \
--to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \
--to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \
--to-keys alice.keys \
--to-identifier 1 \
--amount 10
```
@ -291,8 +302,7 @@ wallet auth-transfer send \
```bash
wallet auth-transfer send \
--from Public/CharlieYrP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPB \
--to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \
--to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \
--to-keys alice.keys \
--to-identifier 2 \
--amount 5
```

169
docs/specs.md Normal file
View File

@ -0,0 +1,169 @@
# LEE v0.3 specifications for Key Agreement
## LEE v0.3 basic types and constants
```rust
/// The ML-KEM-768 KEM ciphertext produced during encapsulation (1088 bytes) of a message.
/// `EphemeralPublicKey` is used to not confuse the ciphertext of an encrypted private account.
type EphemeralPublicKey = [u8; 1088];
/// Private account Viewing Public Key is a ML-KEM-768 encapsulation key (1184 bytes).
type ViewingPublicKey = [u8; 1184];
/// The ML-KEM-768 shared secret (32 bytes).
type SharedSecretKey = [u8; 32];
struct EncryptedAccountData {
ciphertext: Ciphertext,
epk: EphemeralPublicKey,
view_tag: u8, // 1-byte view tag
}
```
#### Key agreement and shared secret
When creating a private account output, the sender uses the recipient's viewing public key to encapsulate a random message that is used to establish a shared secret between the sender and recipient:
- Sender: $(\mathsf{ss},\, \mathsf{epk}) = \mathsf{encapsulate}(\mathsf{vpk\_recipient})$. The 1088-byte ciphertext `epk` is included in the transaction as the `EphemeralPublicKey` field.
- Receiver: $\mathsf{ss} = \mathsf{decapsulate}(\mathsf{epk},\, vsk.d,\, vsk.z)$
where `vpk` is the receiver's `ViewingPublicKey` and `(vsk.d, vsk.z)` are the two 32-byte halves of the receiver's `ViewingSecretKey`.
#### KDF
```rust
fn kdf(
shared_secret: &SharedSecretKey, // 32-byte output of the KEM
commitment: &Commitment, // 32-byte output commitment
output_index: u32, // index of this output within the tx (LE)
) -> [u8; 32] {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"NSSA/v0.2/KDF-SHA256/");
bytes.extend_from_slice(&shared_secret.0);
bytes.extend_from_slice(&commitment.to_byte_array());
bytes.extend_from_slice(&output_index.to_le_bytes());
sha256(bytes)
}
```
### Circuit input
```rust
pub enum InputAccountIdentity {
/// Public account. The guest reads pre/post state from program_outputs and emits no
/// commitment, ciphertext, or nullifier.
Public,
/// Initialization of a standalone private account the caller owns.
/// Pre-state must be Account::default().
/// AccountId = AccountId::from_private(npk(nsk), identifier).
PrivateAuthorizedInit {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
identifier: Identifier,
},
/// Update of an existing standalone private account the caller owns.
/// Membership proof for the current on-chain commitment is required.
PrivateAuthorizedUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
},
/// Initialization of a standalone private account the caller does not own
/// (e.g. a recipient who does not yet exist on chain). No nsk, no membership proof.
PrivateUnauthorized {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
},
/// Initialization of a private PDA.
/// Authorization comes via Claim::Pda(seed) or the caller's pda_seeds.
/// The identifier diversifies the PDA within the (program_id, seed, npk) family.
PrivatePdaInit {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id`
/// rather than requiring a `Claim::Pda` or caller `pda_seeds`. The `pre_state` must
/// have `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
/// Update of an existing private PDA. npk is derived from nsk.
/// Membership proof is required.
PrivatePdaUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == pre_state.account_id`
/// rather than requiring a caller `pda_seeds`. The `pre_state` must have
/// `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
}
```
The `ssk` field carries the **shared secret key** — the 32-byte shared secret used to encrypt the post-state. Note that the key protocol uses `ssk` for "spending secret key" (the master key that derives `nsk` and `vsk`); here `ssk` means the per-output KEM shared secret. It is established via ML-KEM-768:
- Sender: `(ssk, epk) = encapsulate(vpk)`
- Receiver: `ssk = decapsulate(epk, vsk.d, vsk.z)`
where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.z)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`.
## Encrypted private account discovery and tagging
### Ephemeral view tags
Each private account output includes a 1-byte view tag to allow wallets to quickly filter outputs before attempting decryption:
$$\mathsf{ViewTag} = \mathsf{SHA256}(\text{"/LEE/v0.3/ViewTag/"} \;||\; \mathsf{Npk} \;||\; \mathsf{Vpk})[0]$$
where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ML-KEM decapsulation on irrelevant outputs.
### Private account discovery with viewing keys
1. For each encrypted output, compute the expected view tag from `(Npk, Vpk)`. Skip if it does not match.
2. Decapsulate using ML-KEM-768: `ss = decapsulate(epk, vsk.d, vsk.z)`.
3. Run `kdf(ss, commitment, output_index)` to derive the symmetric key.
4. Decrypt the ciphertext with ChaCha20.
5. Parse the 81-byte header to recover `PrivateAccountKind`.
6. Parse the remaining bytes to recover the `Account`.
7. Recompute the account ID from the kind and verify that `Commitment::new(account_id, account)` equals the on-chain commitment. Discard on mismatch (false positive).
```rust
fn private_account_discovery(
tx: &PrivacyPreservingTransaction,
vsk: &ViewingSecretKey,
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
) -> Vec<(PrivateAccountKind, Account)> {
let expected_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let mut discovered = Vec::new();
for (output_index, (encrypted_account, commitment)) in tx.message.encrypted_private_post_states
.iter()
.zip(&tx.message.new_commitments)
.enumerate()
{
if encrypted_account.view_tag != expected_tag {
continue;
}
let ss = SharedSecretKey::decapsulate(&encrypted_account.epk, &vsk.d, &vsk.z);
if let Some((kind, account)) = EncryptionScheme::decrypt(
&encrypted_account.ciphertext, &ss, commitment, output_index as u32
) {
let account_id = AccountId::for_private_account(npk, &kind);
if Commitment::new(&account_id, &account) == *commitment {
discovered.push((kind, account));
}
}
}
discovered
}
```

View File

@ -132,6 +132,7 @@ async fn amm_public() -> Result<()> {
to: Some(public_mention(recipient_account_id_1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
@ -158,6 +159,7 @@ async fn amm_public() -> Result<()> {
to: Some(public_mention(recipient_account_id_2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
@ -530,6 +532,7 @@ async fn amm_new_pool_using_labels() -> Result<()> {
to: Some(public_mention(holding_a_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};
@ -551,6 +554,7 @@ async fn amm_new_pool_using_labels() -> Result<()> {
to: Some(public_mention(holding_b_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};

View File

@ -260,6 +260,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
@ -487,6 +488,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
@ -598,6 +600,7 @@ async fn burn_via_ata_private_owner() -> Result<()> {
to: Some(public_mention(holder_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),

View File

@ -12,8 +12,9 @@ use nssa::{
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
};
use nssa_core::{
InputAccountIdentity, NullifierPublicKey, account::AccountWithMetadata,
encryption::shared_key_derivation::Secp256k1Point,
InputAccountIdentity, NullifierPublicKey,
account::AccountWithMetadata,
encryption::{EphemeralPublicKey, MlKem768EncapsulationKey, ViewingPublicKey},
};
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
@ -38,6 +39,7 @@ async fn private_transfer_to_owned_account() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -71,13 +73,14 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
let to_npk = NullifierPublicKey([42; 32]);
let to_npk_string = hex::encode(to_npk.0);
let to_vpk = Secp256k1Point::from_scalar(to_npk.0);
let to_vpk = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: None,
to_npk: Some(to_npk_string),
to_vpk: Some(hex::encode(to_vpk.0)),
to_vpk: Some(hex::encode(to_vpk.to_bytes())),
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -127,6 +130,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
to: Some(public_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -189,7 +193,8 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())),
to_keys: None,
to_identifier: Some(to.kind.identifier()),
amount: 100,
});
@ -239,6 +244,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -274,14 +280,15 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
let to_npk = NullifierPublicKey([42; 32]);
let to_npk_string = hex::encode(to_npk.0);
let to_vpk = Secp256k1Point::from_scalar(to_npk.0);
let to_vpk = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let from: AccountId = ctx.existing_public_accounts()[0];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(from),
to: None,
to_npk: Some(to_npk_string),
to_vpk: Some(hex::encode(to_vpk.0)),
to_vpk: Some(hex::encode(to_vpk.to_bytes())),
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -351,7 +358,8 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(to.key_chain.viewing_public_key.to_bytes())),
to_keys: None,
to_identifier: Some(to.kind.identifier()),
amount: 100,
});
@ -452,6 +460,7 @@ async fn private_transfer_using_from_label() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -545,7 +554,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
};
let npk_hex = hex::encode(npk.0);
let vpk_hex = hex::encode(vpk.0);
let vpk_hex = hex::encode(vpk.to_bytes());
let identifier_1 = 1_u128;
let identifier_2 = 2_u128;
@ -560,6 +569,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
to: None,
to_npk: Some(npk_hex.clone()),
to_vpk: Some(vpk_hex.clone()),
to_keys: None,
to_identifier: Some(identifier_1),
amount: 100,
}),
@ -573,6 +583,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
to: None,
to_npk: Some(npk_hex),
to_vpk: Some(vpk_hex),
to_keys: None,
to_identifier: Some(identifier_2),
amount: 200,
}),
@ -654,8 +665,9 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
let auth_transfer_program_id = Program::authenticated_transfer_program().id();
let nsk: nssa_core::NullifierSecretKey = [3; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = Secp256k1Point::from_scalar([4; 32]);
let ssk = SharedSecretKey::new([55; 32], &vpk);
let _vpk = MlKem768EncapsulationKey::from_bytes(vec![4_u8; 1184]).unwrap();
let ssk = SharedSecretKey([55_u8; 32]);
let _epk = EphemeralPublicKey(vec![55_u8; 1088]);
let attacker_vault_id = {
let seed = vault_core::compute_vault_seed(attacker_id);
AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337)

View File

@ -25,6 +25,7 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -83,6 +84,7 @@ pub async fn successful_transfer_to_new_account() -> Result<()> {
to: Some(public_mention(new_persistent_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -120,6 +122,7 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 1_000_000,
});
@ -159,6 +162,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -192,6 +196,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -274,6 +279,7 @@ async fn successful_transfer_using_from_label() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -319,6 +325,7 @@ async fn successful_transfer_using_to_label() -> Result<()> {
to: Some(CliAccountMention::Label(label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});

View File

@ -115,6 +115,7 @@ async fn indexer_state_consistency() -> Result<()> {
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -150,6 +151,7 @@ async fn indexer_state_consistency() -> Result<()> {
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -233,6 +235,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> {
to: Some(CliAccountMention::Label(to_label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});

View File

@ -195,6 +195,7 @@ fn indexer_ffi_state_consistency() -> Result<()> {
to_npk: None,
to_vpk: None,
amount: 100,
to_keys: None,
to_identifier: Some(0),
});
@ -234,6 +235,7 @@ fn indexer_ffi_state_consistency() -> Result<()> {
to_npk: None,
to_vpk: None,
amount: 100,
to_keys: None,
to_identifier: Some(0),
});
@ -345,6 +347,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> {
to_npk: None,
to_vpk: None,
amount: 100,
to_keys: None,
to_identifier: Some(0),
});

View File

@ -71,7 +71,10 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
from: private_mention(from),
to: None,
to_npk: Some(hex::encode(to_account.key_chain.nullifier_public_key.0)),
to_vpk: Some(hex::encode(&to_account.key_chain.viewing_public_key.0)),
to_vpk: Some(hex::encode(
to_account.key_chain.viewing_public_key.to_bytes(),
)),
to_keys: None,
to_identifier: Some(to_account.kind.identifier()),
amount: 100,
});
@ -147,6 +150,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
@ -158,6 +162,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 101,
});
@ -197,6 +202,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id3)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 102,
});
@ -208,6 +214,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 103,
});
@ -268,6 +275,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 10,
});
@ -278,6 +286,7 @@ async fn restore_keys_from_seed() -> Result<()> {
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 11,
});

View File

@ -64,9 +64,9 @@ async fn fund_private_pda(
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_account_id);
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.context("failed to serialize auth_transfer instruction")?;
@ -272,10 +272,10 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
// Fresh recipients — hardcoded npks not in any wallet.
let recipient_npk_0 = NullifierPublicKey([0xAA; 32]);
let recipient_vpk_0 = ViewingPublicKey::from_scalar(recipient_npk_0.0);
let recipient_vpk_0 = ViewingPublicKey::from_seed(&[0_u8; 32], &[1_u8; 32]);
let recipient_npk_1 = NullifierPublicKey([0xBB; 32]);
let recipient_vpk_1 = ViewingPublicKey::from_scalar(recipient_npk_1.0);
let recipient_vpk_1 = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]);
let amount_spend_0: u128 = 13;
let amount_spend_1: u128 = 37;

View File

@ -107,8 +107,11 @@ async fn group_invite_join_key_agreement() -> Result<()> {
.key_chain()
.sealing_secret_key()
.context("Sealing key not found")?;
let sealing_pk =
key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk);
let sealing_pk = key_protocol::key_management::group_key_holder::SealingPublicKey::from_bytes(
nssa_core::encryption::ViewingPublicKey::from_seed(&sealing_sk.d, &sealing_sk.z)
.to_bytes()
.to_vec(),
);
let holder = ctx
.wallet()
@ -204,6 +207,7 @@ async fn fund_shared_account_from_public() -> Result<()> {
to: Some(private_mention(shared_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: None,
amount: 100,
});

View File

@ -133,6 +133,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -223,6 +224,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
holder: Some(public_mention(recipient_account_id)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount,
};
@ -365,6 +367,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -554,6 +557,7 @@ async fn create_token_with_private_definition() -> Result<()> {
holder: Some(public_mention(recipient_account_id_public)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount_public,
};
@ -601,6 +605,7 @@ async fn create_token_with_private_definition() -> Result<()> {
holder: Some(private_mention(recipient_account_id_private)),
holder_npk: None,
holder_vpk: None,
holder_keys: None,
holder_identifier: None,
amount: mint_amount_private,
};
@ -740,6 +745,7 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -868,6 +874,7 @@ async fn shielded_token_transfer() -> Result<()> {
to: Some(private_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -991,6 +998,7 @@ async fn deshielded_token_transfer() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};
@ -1124,7 +1132,8 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
definition: private_mention(definition_account_id),
holder: None,
holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)),
holder_vpk: Some(hex::encode(&holder_keys.viewing_public_key.0)),
holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.to_bytes())),
holder_keys: None,
holder_identifier: Some(holder_identifier),
amount: mint_amount,
};
@ -1323,6 +1332,7 @@ async fn transfer_token_using_from_label() -> Result<()> {
to: Some(public_mention(recipient_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: transfer_amount,
};

View File

@ -256,8 +256,7 @@ pub async fn tps_test() -> Result<()> {
fn build_privacy_transaction() -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_nsk = [1; 32];
let sender_vsk = [99; 32];
let sender_vpk = ViewingPublicKey::from_scalar(sender_vsk);
let sender_vpk = ViewingPublicKey::from_seed(&[99_u8; 32], &[100_u8; 32]);
let sender_npk = NullifierPublicKey::from(&sender_nsk);
let sender_pre = AccountWithMetadata::new(
Account {
@ -270,8 +269,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
AccountId::for_regular_private_account(&sender_npk, 0),
);
let recipient_nsk = [2; 32];
let recipient_vsk = [99; 32];
let recipient_vpk = ViewingPublicKey::from_scalar(recipient_vsk);
let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]);
let recipient_npk = NullifierPublicKey::from(&recipient_nsk);
let recipient_pre = AccountWithMetadata::new(
Account::default(),
@ -279,13 +277,13 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
AccountId::for_regular_private_account(&recipient_npk, 0),
);
let eph_holder_from = EphemeralKeyHolder::new(&sender_npk);
let sender_ss = eph_holder_from.calculate_shared_secret_sender(&sender_vpk);
let sender_epk = eph_holder_from.generate_ephemeral_public_key();
let eph_holder_from = EphemeralKeyHolder::new(&sender_vpk);
let sender_ss = eph_holder_from.calculate_shared_secret_sender();
let sender_epk = eph_holder_from.ephemeral_public_key().clone();
let eph_holder_to = EphemeralKeyHolder::new(&recipient_npk);
let recipient_ss = eph_holder_to.calculate_shared_secret_sender(&recipient_vpk);
let recipient_epk = eph_holder_from.generate_ephemeral_public_key();
let eph_holder_to = EphemeralKeyHolder::new(&recipient_vpk);
let recipient_ss = eph_holder_to.calculate_shared_secret_sender();
let recipient_epk = eph_holder_to.ephemeral_public_key().clone();
let balance_to_move: u128 = 1;
let proof: MembershipProof = (

View File

@ -19,6 +19,7 @@ common.workspace = true
anyhow.workspace = true
serde.workspace = true
k256.workspace = true
ml-kem.workspace = true
sha2.workspace = true
rand.workspace = true
hex.workspace = true

View File

@ -1,53 +1,61 @@
use nssa_core::{
NullifierPublicKey, SharedSecretKey,
encryption::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey},
SharedSecretKey,
encryption::{EphemeralPublicKey, ViewingPublicKey},
};
use rand::{RngCore as _, rngs::OsRng};
use sha2::Digest as _;
#[derive(Debug)]
/// Ephemeral secret key holder. Non-clonable as intended for one-time use. Produces ephemeral
/// public keys. Can produce shared secret for sender.
/// Ephemeral key holder for the sender side of a KEM-based shared-secret exchange.
///
/// Non-clonable as intended for one-time use: construction encapsulates once and
/// stores both the shared secret and the ciphertext (`EphemeralPublicKey`) that must
/// be sent to the receiver.
pub struct EphemeralKeyHolder {
ephemeral_secret_key: EphemeralSecretKey,
shared_secret: SharedSecretKey,
ephemeral_public_key: EphemeralPublicKey,
}
// SharedSecretKey does not implement Debug (intentional — leaking key material via
// debug output would be a security risk). We implement Debug manually here, redacting the
// shared secret while still allowing the ephemeral public key (KEM ciphertext) to be inspected.
impl std::fmt::Debug for EphemeralKeyHolder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EphemeralKeyHolder")
.field("shared_secret", &"<redacted>")
.field("ephemeral_public_key", &self.ephemeral_public_key)
.finish()
}
}
impl EphemeralKeyHolder {
#[must_use]
pub fn new(receiver_nullifier_public_key: &NullifierPublicKey) -> Self {
let mut nonce_bytes = [0; 16];
OsRng.fill_bytes(&mut nonce_bytes);
let mut hasher = sha2::Sha256::new();
hasher.update(receiver_nullifier_public_key);
hasher.update(nonce_bytes);
pub fn new(receiver_viewing_public_key: &ViewingPublicKey) -> Self {
let (shared_secret, ephemeral_public_key) =
SharedSecretKey::encapsulate(receiver_viewing_public_key);
Self {
ephemeral_secret_key: hasher.finalize().into(),
shared_secret,
ephemeral_public_key,
}
}
/// Returns the KEM ciphertext to be transmitted to the receiver as the `EphemeralPublicKey`.
#[must_use]
pub fn generate_ephemeral_public_key(&self) -> EphemeralPublicKey {
EphemeralPublicKey::from_scalar(self.ephemeral_secret_key)
pub const fn ephemeral_public_key(&self) -> &EphemeralPublicKey {
&self.ephemeral_public_key
}
/// Returns the sender-side shared secret (established at construction time).
#[must_use]
pub fn calculate_shared_secret_sender(
&self,
receiver_viewing_public_key: &ViewingPublicKey,
) -> SharedSecretKey {
SharedSecretKey::new(self.ephemeral_secret_key, receiver_viewing_public_key)
pub const fn calculate_shared_secret_sender(&self) -> SharedSecretKey {
self.shared_secret
}
}
/// Encapsulates a fresh shared secret toward `vpk` and returns `(shared_secret, ciphertext)`.
///
/// Used when the local side is acting as an "ephemeral receiver" — i.e. generating a
/// one-sided encryption that only the holder of the VSK can decrypt.
#[must_use]
pub fn produce_one_sided_shared_secret_receiver(
vpk: &ViewingPublicKey,
) -> (SharedSecretKey, EphemeralPublicKey) {
let mut esk = [0; 32];
OsRng.fill_bytes(&mut esk);
(
SharedSecretKey::new(esk, vpk),
EphemeralPublicKey::from_scalar(esk),
)
SharedSecretKey::encapsulate(vpk)
}

View File

@ -1,44 +1,39 @@
use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
use nssa_core::{
SharedSecretKey,
encryption::{Scalar, shared_key_derivation::Secp256k1Point},
encryption::{EphemeralPublicKey, MlKem768EncapsulationKey},
program::{PdaSeed, ProgramId},
};
use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, digest::FixedOutput as _};
use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey};
use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey};
/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient.
///
/// Wraps a secp256k1 point but is a distinct type from `ViewingPublicKey` to enforce
/// key separation: viewing keys encrypt account state, sealing keys encrypt the GMS
/// for off-chain distribution.
pub struct SealingPublicKey(Secp256k1Point);
/// Wraps the ML-KEM-768 encapsulation key bytes (1184 bytes). Distinct from
/// `ViewingPublicKey` to enforce key separation: viewing keys encrypt account state,
/// sealing keys encrypt the GMS for off-chain distribution.
pub struct SealingPublicKey(Vec<u8>);
impl SealingPublicKey {
/// Derive the sealing public key from a secret scalar.
#[must_use]
pub fn from_scalar(scalar: Scalar) -> Self {
Self(Secp256k1Point::from_scalar(scalar))
}
/// Construct from raw serialized bytes (e.g. received from another wallet).
/// Construct from raw serialized encapsulation-key bytes (e.g. received from another wallet).
#[must_use]
pub const fn from_bytes(bytes: Vec<u8>) -> Self {
Self(Secp256k1Point(bytes))
Self(bytes)
}
/// Returns the raw bytes for display or transmission.
#[must_use]
pub fn to_bytes(&self) -> &[u8] {
&self.0.0
&self.0
}
}
/// Secret key used to unseal a `GroupKeyHolder` received from another member.
pub type SealingSecretKey = Scalar;
/// Holds the two 32-byte FIPS 203 seed halves `d` and `z`.
pub type SealingSecretKey = ViewingSecretKey;
/// Manages shared viewing keys for a group of controllers owning private PDAs.
///
@ -153,18 +148,17 @@ impl GroupKeyHolder {
/// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`].
///
/// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM
/// to encrypt the payload. The returned bytes are
/// `ephemeral_pubkey (33) || nonce (12) || ciphertext+tag (48)` = 93 bytes.
/// Uses ML-KEM-768 encapsulation to derive a shared secret, then AES-256-GCM to encrypt
/// the payload. The returned bytes are
/// `kem_ciphertext (1088) || nonce (12) || ciphertext+tag (48)` = 1148 bytes.
///
/// Each call generates a fresh ephemeral key, so two seals of the same holder produce
/// Each call generates a fresh KEM encapsulation, so two seals of the same holder produce
/// different ciphertexts.
#[must_use]
pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec<u8> {
let mut ephemeral_scalar: Scalar = [0_u8; 32];
OsRng.fill_bytes(&mut ephemeral_scalar);
let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar);
let shared = SharedSecretKey::new(ephemeral_scalar, &recipient_key.0);
let sealing_key = MlKem768EncapsulationKey::from_bytes(recipient_key.0.clone())
.expect("key_protocol::group_key_holder::GroupKeyHolder::seal_for: SealingPublicKey must be a valid ML-KEM-768 encapsulation key");
let (shared, kem_ct) = SharedSecretKey::encapsulate(&sealing_key);
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -176,12 +170,12 @@ impl GroupKeyHolder {
.encrypt(&nonce, self.gms.as_ref())
.expect("AES-GCM encryption should not fail with valid key/nonce");
let capacity = 33_usize
let capacity = 1088_usize
.checked_add(12)
.and_then(|n| n.checked_add(ciphertext.len()))
.expect("seal capacity overflow");
let mut out = Vec::with_capacity(capacity);
out.extend_from_slice(&ephemeral_pubkey.0);
out.extend_from_slice(&kem_ct.0);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
out
@ -189,20 +183,24 @@ impl GroupKeyHolder {
/// Decrypts a sealed `GroupKeyHolder` using the recipient's [`SealingSecretKey`].
///
/// Returns `Err` if the ciphertext is too short, the ECDH point is invalid, or the
/// AES-GCM authentication tag doesn't verify (wrong key or tampered data).
pub fn unseal(sealed: &[u8], own_key: SealingSecretKey) -> Result<Self, SealError> {
const HEADER_LEN: usize = 33 + 12;
/// Returns `Err` if the ciphertext is too short or the AES-GCM authentication tag
/// doesn't verify (wrong key or tampered data).
pub fn unseal(sealed: &[u8], own_key: &SealingSecretKey) -> Result<Self, SealError> {
// kem_ciphertext (1088) + nonce (12) = header, then AES-GCM tag (16) minimum.
const KEM_CT_LEN: usize = 1088;
const HEADER_LEN: usize = KEM_CT_LEN + 12;
const MIN_LEN: usize = HEADER_LEN + 16;
if sealed.len() < MIN_LEN {
return Err(SealError::TooShort);
}
// MIN_LEN (61) > HEADER_LEN (45), so all slicing below is in bounds.
let ephemeral_pubkey = Secp256k1Point(sealed[..33].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[33..HEADER_LEN]);
let kem_ct = EphemeralPublicKey(sealed[..KEM_CT_LEN].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]);
let ciphertext = &sealed[HEADER_LEN..];
let shared = SharedSecretKey::new(own_key, &ephemeral_pubkey);
let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z)
.expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes");
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -219,7 +217,7 @@ impl GroupKeyHolder {
Ok(Self::from_gms(gms))
}
/// Derives an AES-256 key from the ECDH shared secret via SHA-256 with a domain prefix.
/// Derives an AES-256 key from the ML-KEM shared secret via SHA-256 with a domain prefix.
fn seal_kdf(shared: &SharedSecretKey) -> [u8; 32] {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeySeal/AES\x00\x00\x00\x00\x00\x00";
let mut hasher = sha2::Sha256::new();
@ -407,8 +405,10 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let restored = GroupKeyHolder::unseal(&sealed, recipient_vsk).expect("unseal");
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
let restored = GroupKeyHolder::unseal(&sealed, &recipient_vsk).expect("unseal");
assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms());
@ -433,13 +433,14 @@ mod tests {
.produce_private_key_holder(None)
.generate_viewing_public_key();
let wrong_ssk = SecretSpendingKey([99_u8; 32]);
let wrong_vsk = wrong_ssk
let wrong_vsk = SecretSpendingKey([99_u8; 32])
.produce_private_key_holder(None)
.viewing_secret_key;
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
let result = GroupKeyHolder::unseal(&sealed, wrong_vsk);
let sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
let result = GroupKeyHolder::unseal(&sealed, &wrong_vsk);
assert!(matches!(result, Err(super::SealError::DecryptionFailed)));
}
@ -453,16 +454,18 @@ mod tests {
let recipient_vpk = recipient_keys.generate_viewing_public_key();
let recipient_vsk = recipient_keys.viewing_secret_key;
let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0));
// Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce)
let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(
recipient_vpk.to_bytes().to_vec(),
));
// Flip a byte in the AES-GCM ciphertext portion (after KEM ciphertext + nonce).
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
let result = GroupKeyHolder::unseal(&sealed, recipient_vsk);
let result = GroupKeyHolder::unseal(&sealed, &recipient_vsk);
assert!(matches!(result, Err(super::SealError::DecryptionFailed)));
}
/// Two seals of the same holder produce different ciphertexts (ephemeral randomness).
/// Two seals of the same holder produce different ciphertexts (KEM randomness).
#[test]
fn two_seals_produce_different_ciphertexts() {
let holder = GroupKeyHolder::from_gms([42_u8; 32]);
@ -472,7 +475,7 @@ mod tests {
.produce_private_key_holder(None)
.generate_viewing_public_key();
let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0);
let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.to_bytes().to_vec());
let sealed_a = holder.seal_for(&sealing_key);
let sealed_b = holder.seal_for(&sealing_key);
assert_ne!(sealed_a, sealed_b);
@ -481,14 +484,15 @@ mod tests {
/// Sealed payload is too short.
#[test]
fn unseal_too_short_fails() {
let vsk: SealingSecretKey = [7_u8; 32];
let result = GroupKeyHolder::unseal(&[0_u8; 10], vsk);
let vsk = SealingSecretKey {
d: [7_u8; 32],
z: [0_u8; 32],
};
let result = GroupKeyHolder::unseal(&[0_u8; 10], &vsk);
assert!(matches!(result, Err(super::SealError::TooShort)));
}
/// Degenerate GMS values (all-zeros, all-ones, single-bit) must still produce valid,
/// non-zero, pairwise-distinct npks. Rules out accidental "if gms == default { return
/// default }" style shortcuts in the derivation.
/// Degenerate GMS values must still produce valid, non-zero, pairwise-distinct npks.
#[test]
fn degenerate_gms_produces_distinct_non_zero_keys() {
let seed = PdaSeed::new([1; 32]);
@ -526,21 +530,19 @@ mod tests {
let pda_seed = PdaSeed::new([42_u8; 32]);
let program_id: nssa_core::program::ProgramId = [1; 8];
// Derive Alice's keys
let alice_keys = alice_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed);
let alice_npk = alice_keys.generate_nullifier_public_key();
// Seal GMS for Bob using Bob's viewing key, Bob unseals
let bob_ssk = SecretSpendingKey([77_u8; 32]);
let bob_keys = bob_ssk.produce_private_key_holder(None);
let bob_vpk = bob_keys.generate_viewing_public_key();
let bob_vsk = bob_keys.viewing_secret_key;
let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0));
let sealed =
alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.to_bytes().to_vec()));
let bob_holder =
GroupKeyHolder::unseal(&sealed, bob_vsk).expect("Bob should unseal the GMS");
GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS");
// Key agreement: both derive identical NPK and AccountId
let bob_npk = bob_holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed)
.generate_nullifier_public_key();

View File

@ -1,8 +1,8 @@
use std::collections::BTreeMap;
use k256::{Scalar, elliptic_curve::PrimeField as _};
use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey};
use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::MlKem768EncapsulationKey};
use serde::{Deserialize, Serialize};
use sha2::Digest as _;
use crate::key_management::{
KeyChain,
@ -34,10 +34,10 @@ impl ChildKeysPrivate {
.expect("hash_value is 64 bytes, must be safe to get last 32");
let nsk = ssk.generate_nullifier_secret_key(None);
let vsk = ssk.generate_viewing_secret_key(None);
let vsk = ssk.generate_viewing_secret_seed_key(None);
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_scalar(vsk);
let vpk = MlKem768EncapsulationKey::from(&vsk);
Self {
value: (
@ -59,16 +59,20 @@ impl ChildKeysPrivate {
#[must_use]
pub fn nth_child(&self, cci: u32) -> Self {
#[expect(clippy::arithmetic_side_effects, reason = "TODO: fix later")]
let parent_pt =
Scalar::from_repr(self.value.0.private_key_holder.nullifier_secret_key.into())
.expect("Key generated as scalar, must be valid representation")
* Scalar::from_repr(self.value.0.private_key_holder.viewing_secret_key.into())
.expect("Key generated as scalar, must be valid representation");
let mut input = vec![];
// `parent_hash`` is used to incorporate entropy based on the parent node's keys
// to generate the `ssk` and `ccc` values.
let mut parent_hash = sha2::Sha256::new();
parent_hash.update(b"LEE/keys");
parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d);
parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z);
let parent_pt = parent_hash.finalize();
// Each child (of the same parent node) share the same `parent_pt`.
// To ensure that each child generates unique keys, we include the child index.
let mut input = vec![];
input.extend_from_slice(b"LEE_seed_priv");
input.extend_from_slice(&parent_pt.to_bytes());
input.extend_from_slice(&parent_pt);
#[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")]
input.extend_from_slice(&cci.to_be_bytes());
@ -84,10 +88,10 @@ impl ChildKeysPrivate {
.expect("hash_value is 64 bytes, must be safe to get last 32");
let nsk = ssk.generate_nullifier_secret_key(Some(cci));
let vsk = ssk.generate_viewing_secret_key(Some(cci));
let vsk = ssk.generate_viewing_secret_seed_key(Some(cci));
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_scalar(vsk);
let vpk = MlKem768EncapsulationKey::from(&vsk);
Self {
value: (
@ -128,12 +132,11 @@ impl KeyTreeNode for ChildKeysPrivate {
#[cfg(test)]
mod tests {
use nssa_core::{NullifierPublicKey, NullifierSecretKey};
use nssa_core::NullifierSecretKey;
use super::*;
use crate::key_management::{self, secret_holders::ViewingSecretKey};
#[expect(clippy::redundant_type_annotations, reason = "TODO: clippy requires")]
#[test]
fn master_key_generation() {
let seed: [u8; 64] = [
@ -145,7 +148,7 @@ mod tests {
let keys = ChildKeysPrivate::root(seed);
let expected_ssk: SecretSpendingKey = key_management::secret_holders::SecretSpendingKey([
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
246, 79, 26, 124, 135, 95, 52, 51, 201, 27, 48, 194, 2, 144, 51, 219, 245, 128, 139,
222, 42, 195, 105, 33, 115, 97, 186, 0, 97, 14, 218, 191,
]);
@ -160,26 +163,92 @@ mod tests {
34, 234, 19, 222, 2, 22, 12, 163, 252, 88, 11, 0, 163,
];
let expected_npk: NullifierPublicKey = nssa_core::NullifierPublicKey([
let expected_npk = nssa_core::NullifierPublicKey([
7, 123, 125, 191, 233, 183, 201, 4, 20, 214, 155, 210, 45, 234, 27, 240, 194, 111, 97,
247, 155, 113, 122, 246, 192, 0, 70, 61, 76, 71, 70, 2,
]);
let expected_vsk = [
155, 90, 54, 75, 228, 130, 68, 201, 129, 251, 180, 195, 250, 64, 34, 230, 241, 204,
216, 50, 149, 156, 10, 67, 208, 74, 9, 10, 47, 59, 50, 202,
];
let expected_vpk_as_bytes: [u8; 33] = [
2, 191, 99, 102, 114, 40, 131, 109, 166, 8, 222, 186, 107, 29, 156, 106, 206, 96, 127,
80, 170, 66, 217, 79, 38, 80, 11, 74, 147, 123, 221, 159, 166,
];
let expected_vsk = ViewingSecretKey::new(
[
187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194,
159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36,
],
[
80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19,
223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82,
],
);
let expected_vpk: [u8; 1184] = [
127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220,
150, 100, 203, 34, 34, 127, 232, 156, 43, 218, 109, 6, 160, 67, 35, 210, 194, 25, 181,
118, 237, 25, 129, 51, 160, 189, 51, 99, 184, 57, 28, 121, 240, 236, 2, 170, 198, 26,
91, 172, 110, 52, 32, 186, 35, 179, 202, 234, 249, 15, 242, 100, 198, 168, 163, 120,
205, 118, 85, 195, 210, 187, 95, 150, 154, 8, 68, 165, 237, 87, 166, 101, 57, 4, 18,
11, 122, 235, 180, 199, 154, 165, 158, 55, 136, 30, 237, 43, 167, 215, 68, 80, 102, 0,
71, 90, 130, 206, 240, 215, 69, 199, 83, 7, 60, 184, 128, 230, 184, 61, 93, 201, 204,
165, 104, 9, 127, 220, 52, 246, 217, 131, 251, 2, 170, 133, 6, 51, 40, 224, 101, 61,
16, 135, 32, 182, 201, 68, 58, 171, 54, 161, 184, 243, 38, 106, 200, 251, 17, 172, 8,
24, 73, 230, 55, 85, 20, 147, 222, 165, 200, 116, 135, 47, 20, 227, 56, 220, 64, 120,
215, 245, 58, 86, 102, 149, 252, 193, 163, 160, 59, 82, 138, 249, 171, 1, 54, 199, 193,
171, 85, 38, 64, 56, 121, 106, 84, 57, 252, 94, 147, 16, 191, 196, 104, 47, 129, 84,
21, 252, 160, 81, 207, 184, 199, 3, 177, 74, 117, 115, 175, 138, 108, 36, 198, 5, 32,
15, 218, 3, 20, 19, 15, 251, 209, 86, 128, 139, 148, 78, 10, 34, 144, 149, 74, 102, 48,
59, 70, 124, 47, 193, 100, 26, 9, 104, 178, 102, 156, 199, 242, 101, 147, 161, 87, 27,
234, 192, 204, 41, 36, 43, 83, 219, 15, 211, 66, 91, 76, 73, 13, 113, 155, 203, 193,
160, 130, 84, 103, 47, 70, 100, 147, 169, 65, 119, 84, 121, 122, 161, 76, 203, 144,
248, 145, 22, 8, 46, 121, 44, 77, 20, 149, 66, 179, 56, 149, 231, 98, 184, 9, 64, 14,
67, 196, 34, 8, 123, 21, 80, 169, 168, 223, 230, 133, 0, 66, 159, 230, 69, 201, 205,
169, 105, 196, 21, 71, 84, 70, 58, 165, 165, 134, 186, 232, 60, 70, 51, 57, 239, 74,
174, 116, 234, 36, 178, 49, 42, 168, 250, 104, 141, 106, 0, 109, 52, 86, 104, 243, 62,
214, 137, 48, 107, 2, 152, 206, 227, 175, 147, 236, 19, 113, 27, 191, 231, 235, 167,
114, 104, 23, 126, 203, 94, 242, 149, 171, 115, 170, 89, 244, 58, 29, 176, 73, 203, 44,
8, 32, 9, 226, 32, 78, 246, 38, 235, 149, 133, 25, 243, 47, 124, 180, 200, 211, 165,
137, 56, 169, 117, 31, 244, 65, 91, 135, 146, 158, 20, 75, 102, 32, 65, 250, 103, 199,
36, 48, 31, 155, 164, 191, 222, 85, 37, 66, 243, 17, 120, 104, 0, 228, 83, 200, 116, 6,
199, 106, 236, 139, 246, 216, 152, 241, 211, 85, 106, 200, 44, 231, 240, 66, 3, 193,
147, 16, 145, 65, 49, 33, 53, 247, 69, 47, 44, 113, 86, 117, 6, 20, 193, 183, 128, 178,
181, 21, 251, 99, 39, 149, 210, 146, 106, 181, 186, 7, 36, 63, 186, 234, 191, 164, 193,
162, 127, 250, 122, 189, 219, 21, 92, 48, 86, 209, 184, 99, 160, 201, 162, 145, 20,
138, 154, 18, 37, 180, 209, 165, 165, 51, 187, 78, 193, 175, 135, 6, 55, 216, 178, 10,
40, 246, 98, 128, 80, 14, 38, 69, 113, 123, 54, 94, 43, 50, 106, 167, 17, 77, 163, 148,
117, 225, 9, 7, 253, 240, 157, 96, 103, 33, 100, 37, 37, 20, 53, 138, 234, 55, 45, 232,
154, 9, 150, 192, 116, 36, 119, 106, 95, 119, 34, 220, 84, 174, 19, 227, 33, 209, 96,
197, 148, 230, 197, 59, 117, 130, 7, 116, 11, 0, 197, 16, 249, 151, 31, 4, 64, 29, 165,
247, 110, 176, 166, 4, 112, 136, 101, 208, 7, 179, 38, 183, 134, 58, 107, 207, 160, 38,
159, 67, 112, 20, 225, 199, 179, 133, 117, 144, 54, 199, 15, 204, 80, 154, 116, 84, 88,
109, 113, 5, 207, 226, 21, 62, 247, 122, 14, 156, 9, 8, 76, 26, 148, 67, 196, 128, 176,
78, 51, 161, 151, 75, 248, 154, 31, 168, 9, 4, 3, 107, 222, 245, 178, 21, 84, 7, 25,
155, 118, 97, 135, 63, 89, 233, 11, 207, 148, 155, 38, 106, 104, 102, 140, 104, 67,
149, 20, 30, 196, 44, 197, 128, 34, 182, 80, 30, 32, 137, 34, 212, 164, 177, 164, 12,
115, 41, 156, 111, 71, 230, 120, 111, 218, 25, 117, 218, 75, 167, 32, 37, 57, 50, 99,
181, 203, 40, 105, 248, 150, 114, 121, 73, 127, 198, 191, 161, 44, 56, 213, 243, 71, 2,
56, 192, 243, 107, 179, 27, 96, 21, 116, 169, 64, 15, 97, 166, 151, 200, 11, 40, 204,
71, 168, 220, 9, 55, 43, 146, 244, 212, 166, 192, 180, 189, 237, 162, 42, 29, 33, 52,
193, 4, 178, 157, 244, 28, 209, 44, 26, 36, 147, 126, 94, 164, 37, 47, 115, 38, 23,
165, 96, 106, 140, 42, 69, 146, 194, 93, 71, 175, 49, 147, 32, 246, 97, 94, 41, 116,
127, 174, 18, 16, 14, 163, 17, 180, 213, 203, 166, 33, 139, 214, 18, 170, 27, 41, 59,
175, 200, 101, 14, 128, 45, 179, 167, 136, 232, 138, 56, 124, 145, 75, 233, 132, 161,
196, 164, 72, 80, 60, 187, 38, 90, 90, 17, 66, 134, 59, 2, 165, 29, 76, 24, 38, 211,
177, 83, 119, 20, 239, 59, 77, 34, 3, 42, 47, 60, 89, 46, 103, 168, 120, 17, 199, 50,
17, 103, 107, 48, 8, 53, 220, 159, 212, 65, 198, 80, 8, 11, 235, 97, 203, 196, 240, 44,
56, 121, 77, 91, 196, 160, 129, 242, 149, 226, 57, 106, 180, 76, 161, 203, 18, 37, 166,
153, 44, 40, 28, 74, 8, 11, 6, 166, 54, 10, 103, 247, 23, 35, 7, 47, 173, 133, 71, 85,
3, 168, 250, 120, 126, 174, 37, 80, 128, 107, 7, 161, 130, 155, 136, 92, 48, 215, 119,
196, 124, 85, 157, 234, 2, 166, 137, 65, 121, 222, 112, 47, 17, 43, 23, 111, 88, 5,
195, 41, 8, 191, 227, 21, 173, 35, 199, 196, 188, 162, 191, 195, 204, 137, 54, 16, 73,
178, 150, 249, 234, 22, 216, 123, 157, 144, 218, 118, 53, 193, 67, 65, 84, 162, 244,
165, 24, 110, 246, 146, 228, 212, 180, 150, 116, 201, 37, 128, 76, 41, 188, 42, 79,
148, 52, 196, 176, 178, 224, 48, 168, 13, 129, 193, 131, 185, 131, 93, 40, 145, 56,
180, 29, 153, 83, 39, 69, 232, 96, 238, 137, 104, 150, 2, 202, 239, 149, 248, 154, 115,
115, 127, 3, 8, 32, 61, 96, 66, 25, 181, 14, 72, 73, 97, 186, 134, 140, 33, 69, 33, 74,
];
assert!(expected_ssk == keys.value.0.secret_spending_key);
assert!(expected_ccc == keys.ccc);
assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key);
assert!(expected_npk == keys.value.0.nullifier_public_key);
assert!(expected_vsk == keys.value.0.private_key_holder.viewing_secret_key);
assert!(expected_vpk_as_bytes == keys.value.0.viewing_public_key.to_bytes());
assert!(expected_vpk == keys.value.0.viewing_public_key.to_bytes());
}
#[test]
@ -194,33 +263,107 @@ mod tests {
let root_node = ChildKeysPrivate::root(seed);
let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32);
let expected_ccc: [u8; 32] = [
27, 73, 133, 213, 214, 63, 217, 184, 164, 17, 172, 140, 223, 95, 255, 157, 11, 0, 58,
53, 82, 147, 121, 120, 199, 50, 30, 28, 103, 24, 121, 187,
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
215, 207, 70, 52, 161, 220, 88, 88, 241, 149, 81, 130, 217, 214, 252, 170, 51, 232,
230, 158, 195, 173, 174, 37, 27, 101, 49, 35, 79, 13, 44, 225,
]);
let expected_ccc = [
113, 136, 96, 232, 12, 136, 185, 254, 36, 103, 64, 44, 238, 176, 240, 92, 219, 184,
143, 35, 183, 54, 170, 15, 126, 56, 115, 21, 89, 142, 236, 217,
];
let expected_nsk: NullifierSecretKey = [
124, 61, 40, 92, 33, 135, 3, 41, 200, 234, 3, 69, 102, 184, 57, 191, 106, 151, 194,
192, 103, 132, 141, 112, 249, 108, 192, 117, 24, 48, 70, 216,
27, 167, 3, 140, 113, 16, 209, 83, 21, 77, 65, 91, 26, 191, 203, 102, 66, 140, 157,
220, 101, 104, 227, 135, 216, 215, 216, 126, 194, 196, 43, 34,
];
let expected_npk = nssa_core::NullifierPublicKey([
116, 231, 246, 189, 145, 240, 37, 59, 219, 223, 216, 246, 116, 171, 223, 55, 197, 200,
134, 192, 221, 40, 218, 167, 239, 5, 11, 95, 147, 247, 162, 226,
30, 208, 29, 96, 156, 95, 79, 16, 182, 0, 10, 194, 209, 90, 35, 177, 110, 224, 247, 67,
219, 114, 113, 16, 42, 27, 220, 96, 151, 124, 8, 65,
]);
let expected_vsk: ViewingSecretKey = [
33, 155, 68, 60, 102, 70, 47, 105, 194, 129, 44, 26, 143, 198, 44, 244, 185, 31, 236,
252, 205, 89, 138, 107, 39, 38, 154, 73, 109, 166, 41, 114,
];
let expected_vpk_as_bytes: [u8; 33] = [
2, 78, 213, 113, 117, 105, 162, 248, 175, 68, 128, 232, 106, 204, 208, 159, 11, 78, 48,
244, 127, 112, 46, 0, 93, 184, 1, 77, 132, 160, 75, 152, 88,
let expected_vsk = ViewingSecretKey::new(
[
81, 154, 68, 152, 72, 163, 82, 17, 125, 156, 193, 135, 129, 93, 227, 55, 224, 104,
119, 232, 13, 101, 241, 20, 175, 72, 192, 186, 176, 246, 140, 211,
],
[
31, 40, 109, 41, 185, 61, 173, 79, 102, 171, 158, 245, 232, 71, 57, 157, 142, 117,
184, 235, 216, 71, 55, 44, 33, 156, 167, 133, 184, 92, 47, 174,
],
);
let expected_vpk: [u8; 1184] = [
67, 150, 145, 133, 41, 124, 194, 102, 104, 131, 195, 8, 168, 170, 200, 40, 210, 84, 85,
117, 50, 99, 52, 23, 144, 23, 22, 140, 187, 76, 49, 224, 189, 64, 249, 72, 219, 35, 49,
162, 146, 121, 27, 179, 183, 215, 84, 177, 62, 37, 103, 97, 209, 201, 8, 162, 38, 109,
87, 44, 103, 136, 112, 236, 120, 60, 235, 130, 60, 212, 209, 77, 77, 220, 28, 156, 34,
7, 31, 35, 179, 102, 21, 54, 77, 99, 157, 210, 247, 151, 214, 182, 30, 57, 219, 40, 42,
188, 32, 30, 134, 126, 7, 22, 51, 241, 152, 8, 96, 5, 87, 168, 64, 62, 81, 247, 33,
228, 44, 180, 203, 60, 49, 66, 247, 143, 113, 106, 189, 44, 11, 182, 213, 247, 9, 22,
3, 208, 125, 2, 8, 103, 195, 202, 21, 33, 72, 139, 233, 19, 171, 172, 69, 253, 212, 37,
197, 66, 165, 207, 168, 69, 18, 24, 1, 100, 200, 175, 163, 247, 115, 17, 124, 84, 183,
92, 96, 142, 204, 149, 2, 90, 53, 110, 246, 188, 135, 240, 160, 231, 145, 23, 90, 209,
93, 166, 17, 119, 240, 49, 67, 234, 41, 187, 71, 23, 152, 159, 54, 206, 207, 26, 11,
32, 134, 202, 185, 201, 25, 59, 199, 182, 18, 236, 175, 254, 227, 195, 98, 52, 139,
162, 172, 195, 102, 178, 115, 59, 113, 108, 96, 89, 175, 145, 71, 202, 231, 153, 69, 3,
25, 60, 43, 215, 35, 70, 119, 16, 235, 98, 184, 252, 50, 36, 161, 244, 57, 13, 214,
115, 106, 225, 166, 7, 59, 44, 130, 197, 85, 69, 220, 81, 10, 1, 130, 227, 225, 47, 78,
251, 49, 232, 55, 2, 66, 64, 180, 220, 65, 140, 231, 188, 172, 153, 153, 152, 15, 186,
74, 6, 39, 16, 251, 216, 165, 145, 134, 3, 88, 131, 80, 114, 156, 119, 72, 130, 54,
159, 202, 23, 7, 130, 127, 156, 252, 113, 108, 85, 22, 120, 104, 12, 151, 187, 102, 64,
96, 137, 184, 68, 201, 20, 196, 196, 226, 220, 139, 174, 76, 109, 1, 179, 81, 156, 26,
136, 238, 106, 41, 197, 18, 16, 179, 91, 9, 8, 213, 123, 108, 58, 3, 102, 12, 87, 92,
217, 207, 166, 131, 17, 218, 134, 170, 27, 129, 145, 0, 65, 85, 99, 163, 97, 78, 228,
15, 54, 85, 201, 58, 204, 160, 250, 66, 41, 36, 165, 78, 50, 137, 78, 197, 103, 57, 79,
26, 14, 167, 104, 165, 129, 128, 90, 104, 148, 121, 135, 24, 126, 139, 235, 84, 183,
165, 115, 111, 83, 48, 184, 55, 84, 250, 115, 171, 195, 91, 114, 213, 104, 51, 110, 86,
148, 37, 139, 83, 49, 165, 171, 144, 90, 19, 91, 195, 111, 82, 185, 133, 211, 24, 186,
48, 230, 172, 190, 6, 65, 230, 26, 139, 8, 9, 34, 54, 28, 103, 84, 116, 38, 252, 105,
86, 123, 40, 31, 39, 64, 14, 253, 215, 147, 182, 218, 111, 148, 2, 18, 3, 197, 4, 129,
107, 136, 89, 122, 56, 47, 9, 179, 66, 227, 24, 193, 32, 4, 172, 210, 29, 152, 114,
134, 65, 249, 201, 178, 16, 206, 209, 39, 193, 109, 91, 122, 194, 26, 206, 37, 227, 55,
160, 214, 85, 196, 64, 97, 96, 66, 80, 34, 177, 83, 200, 44, 137, 175, 149, 114, 42,
229, 168, 248, 96, 106, 110, 182, 155, 62, 27, 179, 229, 139, 9, 213, 181, 116, 59,
118, 142, 91, 23, 165, 80, 43, 118, 18, 41, 143, 125, 59, 102, 61, 224, 120, 186, 10,
63, 119, 241, 168, 196, 87, 117, 138, 3, 151, 1, 129, 76, 154, 87, 200, 114, 124, 90,
212, 182, 54, 94, 20, 165, 243, 88, 77, 76, 152, 69, 19, 164, 106, 196, 204, 46, 239,
116, 42, 179, 65, 79, 39, 145, 63, 169, 199, 142, 6, 103, 118, 130, 49, 184, 208, 203,
36, 162, 216, 9, 188, 17, 86, 45, 35, 20, 178, 218, 121, 164, 243, 145, 57, 208, 130,
26, 27, 28, 100, 161, 148, 195, 54, 66, 114, 108, 146, 135, 66, 69, 232, 33, 197, 213,
131, 107, 31, 19, 162, 155, 164, 161, 103, 8, 192, 127, 188, 196, 252, 2, 155, 18, 130,
105, 53, 235, 200, 87, 203, 162, 95, 50, 158, 96, 210, 1, 45, 8, 26, 3, 192, 201, 182,
148, 192, 157, 106, 5, 161, 248, 66, 89, 56, 141, 126, 243, 143, 68, 90, 133, 193, 181,
198, 3, 169, 72, 66, 215, 195, 38, 37, 196, 103, 229, 89, 162, 210, 118, 12, 233, 162,
95, 164, 107, 97, 11, 120, 255, 164, 60, 117, 37, 108, 144, 185, 167, 40, 124, 69, 23,
37, 148, 222, 233, 43, 50, 16, 58, 53, 252, 8, 102, 88, 109, 28, 18, 22, 5, 49, 66,
149, 114, 203, 95, 216, 175, 10, 87, 206, 46, 9, 101, 212, 226, 84, 4, 231, 161, 106,
185, 31, 6, 101, 27, 54, 49, 85, 54, 84, 12, 250, 4, 49, 184, 134, 186, 23, 146, 54,
90, 186, 134, 129, 68, 10, 241, 201, 65, 251, 69, 110, 127, 220, 148, 38, 250, 148, 83,
32, 100, 131, 83, 133, 195, 54, 132, 63, 229, 85, 34, 172, 126, 68, 99, 197, 18, 197,
91, 221, 234, 66, 203, 156, 73, 46, 0, 54, 205, 11, 52, 172, 114, 193, 127, 171, 134,
109, 92, 37, 124, 181, 167, 191, 209, 148, 232, 26, 136, 230, 133, 181, 248, 117, 11,
45, 156, 136, 117, 144, 126, 239, 230, 144, 90, 57, 109, 158, 167, 19, 131, 215, 136,
85, 136, 10, 49, 9, 146, 64, 81, 28, 171, 53, 78, 40, 225, 94, 238, 70, 174, 125, 186,
155, 177, 202, 157, 63, 39, 152, 44, 105, 184, 140, 179, 204, 32, 210, 109, 35, 150,
194, 14, 98, 148, 176, 73, 185, 49, 135, 135, 244, 151, 147, 17, 103, 35, 242, 130, 3,
158, 198, 152, 83, 240, 198, 254, 145, 181, 67, 163, 14, 237, 249, 179, 252, 220, 67,
239, 7, 118, 131, 229, 137, 172, 151, 57, 121, 138, 204, 6, 208, 52, 168, 236, 123,
104, 68, 36, 141, 25, 168, 56, 199, 40, 200, 52, 97, 59, 55, 184, 196, 234, 204, 108,
75, 65, 177, 82, 207, 127, 128, 157, 0, 68, 163, 127, 152, 85, 123, 209, 163, 21, 119,
62, 250, 236, 58, 229, 220, 99, 209, 147, 10, 177, 115, 172, 96, 192, 80, 240, 66, 191,
138, 91, 52, 200, 132, 126, 255, 69, 98, 12, 140, 8, 158, 2, 153, 66, 211, 74, 242,
147, 148, 209, 6, 161, 76, 149, 158, 209, 163, 20, 76, 75, 192, 193, 162, 71, 134, 72,
160, 192, 10, 203, 4, 213, 23, 140, 196, 39, 231, 39, 16, 209, 228, 112, 244, 29, 27,
181, 190, 19, 134, 116, 173, 135, 190, 118, 4, 214, 194, 189, 224, 164, 91, 211, 182,
162, 226,
];
assert!(expected_ssk == child_node.value.0.secret_spending_key);
assert!(expected_ccc == child_node.ccc);
assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key);
assert!(expected_npk == child_node.value.0.nullifier_public_key);
assert!(expected_vsk == child_node.value.0.private_key_holder.viewing_secret_key);
assert!(expected_vpk_as_bytes == child_node.value.0.viewing_public_key.to_bytes());
assert!(expected_vpk == child_node.value.0.viewing_public_key.to_bytes());
}
}

View File

@ -69,21 +69,15 @@ impl KeyChain {
pub fn calculate_shared_secret_receiver(
&self,
ephemeral_public_key_sender: &EphemeralPublicKey,
index: Option<u32>,
) -> SharedSecretKey {
SharedSecretKey::new(
self.secret_spending_key.generate_viewing_secret_key(index),
ephemeral_public_key_sender,
)
) -> Option<SharedSecretKey> {
let vsk = &self.private_key_holder.viewing_secret_key;
SharedSecretKey::decapsulate(ephemeral_public_key_sender, &vsk.d, &vsk.z)
}
}
#[cfg(test)]
mod tests {
use aes_gcm::aead::OsRng;
use base58::ToBase58 as _;
use k256::{AffinePoint, elliptic_curve::group::GroupEncoding as _};
use rand::RngCore as _;
use super::*;
use crate::key_management::{
@ -106,14 +100,31 @@ mod tests {
fn calculate_shared_secret_receiver() {
let account_id_key_holder = KeyChain::new_os_random();
// Generate a random ephemeral public key sender
let mut scalar = [0; 32];
OsRng.fill_bytes(&mut scalar);
let ephemeral_public_key_sender = EphemeralPublicKey::from_scalar(scalar);
// Create a proper KEM ciphertext by encapsulating toward this key chain's VPK.
let (_, epk) = SharedSecretKey::encapsulate(&account_id_key_holder.viewing_public_key);
// Calculate shared secret
let _shared_secret = account_id_key_holder
.calculate_shared_secret_receiver(&ephemeral_public_key_sender, None);
let _shared_secret = account_id_key_holder.calculate_shared_secret_receiver(&epk);
}
#[test]
fn calculate_shared_secret_receiver_returns_none_for_malformed_epk() {
let key_chain = KeyChain::new_os_random();
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
key_chain
.calculate_shared_secret_receiver(&short_epk)
.is_none(),
"short EphemeralPublicKey must return None"
);
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
assert!(
key_chain
.calculate_shared_secret_receiver(&long_epk)
.is_none(),
"long EphemeralPublicKey must return None"
);
}
#[test]
@ -135,12 +146,6 @@ mod tests {
println!("======Prerequisites======");
println!();
println!(
"Group generator {:?}",
hex::encode(AffinePoint::GENERATOR.to_bytes())
);
println!();
println!("======Holders======");
println!();
@ -188,14 +193,12 @@ mod tests {
fn non_trivial_chain_index() {
let keys = account_with_chain_index_2_for_tests();
let eph_key_holder = EphemeralKeyHolder::new(&keys.nullifier_public_key);
let eph_key_holder = EphemeralKeyHolder::new(&keys.viewing_public_key);
let key_sender = eph_key_holder.calculate_shared_secret_sender(&keys.viewing_public_key);
let key_receiver = keys.calculate_shared_secret_receiver(
&eph_key_holder.generate_ephemeral_public_key(),
Some(2),
);
let key_sender = eph_key_holder.calculate_shared_secret_sender();
let key_receiver =
keys.calculate_shared_secret_receiver(eph_key_holder.ephemeral_public_key());
assert_eq!(key_sender.0, key_receiver.0);
assert_eq!(key_sender.0, key_receiver.unwrap().0);
}
}

View File

@ -1,9 +1,7 @@
use bip39::Mnemonic;
use common::HashType;
use nssa_core::{
NullifierPublicKey, NullifierSecretKey,
encryption::{Scalar, ViewingPublicKey},
};
use ml_kem;
use nssa_core::{NullifierPublicKey, NullifierSecretKey, encryption::MlKem768EncapsulationKey};
use rand::{RngCore as _, rngs::OsRng};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, digest::FixedOutput as _};
@ -19,8 +17,20 @@ pub struct SeedHolder {
/// Secret spending key object. Can produce `PrivateKeyHolder` objects.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SecretSpendingKey(pub [u8; 32]);
/// Viewing secret key: the FIPS 203 KEM seed split into its two 32-byte halves `d` and `z`,
/// from which the ML-KEM-768 decapsulation key is derived deterministically.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViewingSecretKey {
pub d: [u8; 32],
pub z: [u8; 32],
}
pub type ViewingSecretKey = Scalar;
impl ViewingSecretKey {
#[must_use]
pub const fn new(d: [u8; 32], z: [u8; 32]) -> Self {
Self { d, z }
}
}
/// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret
/// for recepient.
@ -114,7 +124,7 @@ impl SecretSpendingKey {
#[must_use]
#[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")]
pub fn generate_viewing_secret_key(&self, index: Option<u32>) -> ViewingSecretKey {
pub fn generate_viewing_secret_seed_key(&self, index: Option<u32>) -> ViewingSecretKey {
const PREFIX: &[u8; 8] = b"LEE/keys";
const SUFFIX_1: &[u8; 1] = &[2];
const SUFFIX_2: &[u8; 19] = &[0; 19];
@ -124,25 +134,57 @@ impl SecretSpendingKey {
_ => index.expect("Expect a valid u32"),
};
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
hasher.update(self.0);
hasher.update(SUFFIX_1);
hasher.update(index.to_be_bytes());
hasher.update(SUFFIX_2);
let mut bytes: Vec<u8> = Vec::with_capacity(64);
bytes.extend_from_slice(PREFIX);
bytes.extend_from_slice(&self.0);
bytes.extend_from_slice(SUFFIX_1);
bytes.extend_from_slice(&index.to_be_bytes());
bytes.extend_from_slice(SUFFIX_2);
let bytes: [u8; 64] = bytes
.try_into()
.expect("`generate_viewing_secret_seed_key`: bytes must be exactly 64");
hasher.finalize_fixed().into()
let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed");
ViewingSecretKey::new(
*full_seed
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
*full_seed
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32"),
)
}
#[must_use]
pub const fn generate_viewing_secret_key(seed: [u8; 64]) -> ViewingSecretKey {
ViewingSecretKey::new(
*seed.first_chunk::<32>().expect("seed is 64 bytes"),
*seed.last_chunk::<32>().expect("seed is 64 bytes"),
)
}
#[must_use]
pub fn produce_private_key_holder(&self, index: Option<u32>) -> PrivateKeyHolder {
PrivateKeyHolder {
nullifier_secret_key: self.generate_nullifier_secret_key(index),
viewing_secret_key: self.generate_viewing_secret_key(index),
viewing_secret_key: self.generate_viewing_secret_seed_key(index),
}
}
}
impl From<&ViewingSecretKey> for MlKem768EncapsulationKey {
fn from(sk: &ViewingSecretKey) -> Self {
use ml_kem::{Kem, KeyExport as _, MlKem768, Seed};
let mut seed_bytes = [0_u8; 64];
seed_bytes[..32].copy_from_slice(&sk.d);
seed_bytes[32..].copy_from_slice(&sk.z);
let dk = <MlKem768 as Kem>::DecapsulationKey::from_seed(Seed::from(seed_bytes));
Self::from_bytes(dk.encapsulation_key().to_bytes().to_vec())
.expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always 1184 bytes")
}
}
impl PrivateKeyHolder {
#[must_use]
pub fn generate_nullifier_public_key(&self) -> NullifierPublicKey {
@ -150,8 +192,8 @@ impl PrivateKeyHolder {
}
#[must_use]
pub fn generate_viewing_public_key(&self) -> ViewingPublicKey {
ViewingPublicKey::from_scalar(self.viewing_secret_key)
pub fn generate_viewing_public_key(&self) -> MlKem768EncapsulationKey {
MlKem768EncapsulationKey::from(&self.viewing_secret_key)
}
}
@ -183,8 +225,7 @@ mod tests {
assert_eq!(seed_holder.seed.len(), 64);
let top_secret_key_holder = seed_holder.produce_top_secret_key_holder();
let _vsk = top_secret_key_holder.generate_viewing_secret_key(None);
let _vsk = top_secret_key_holder.generate_viewing_secret_seed_key(None);
}
#[test]

View File

@ -31,6 +31,7 @@ risc0-build = "3.0.3"
risc0-binfmt = "3.0.2"
[dev-dependencies]
nssa_core = { workspace = true, features = ["test_utils"] }
token_core.workspace = true
authenticated_transfer_core.workspace = true
test_program_methods.workspace = true

View File

@ -16,7 +16,7 @@ thiserror.workspace = true
bytemuck.workspace = true
bytesize.workspace = true
base58.workspace = true
k256 = { workspace = true, optional = true }
ml-kem = { workspace = true, optional = true, features = ["getrandom"] }
chacha20 = { version = "0.10" }
[dev-dependencies]
@ -24,4 +24,5 @@ serde_json.workspace = true
[features]
default = []
host = ["dep:k256"]
host = ["dep:ml-kem"]
test_utils = ["host"]

View File

@ -7,7 +7,7 @@ use std::io::Read as _;
#[cfg(feature = "host")]
use crate::Nullifier;
#[cfg(feature = "host")]
use crate::encryption::shared_key_derivation::Secp256k1Point;
use crate::encryption::EphemeralPublicKey;
#[cfg(feature = "host")]
use crate::error::NssaCoreError;
use crate::{
@ -158,16 +158,17 @@ impl Ciphertext {
}
#[cfg(feature = "host")]
impl Secp256k1Point {
/// Converts the point to bytes.
impl EphemeralPublicKey {
/// Serializes the ML-KEM-768 ciphertext to bytes (always 1088 bytes).
#[must_use]
pub fn to_bytes(&self) -> [u8; 33] {
self.0.clone().try_into().unwrap()
pub fn to_bytes(&self) -> Vec<u8> {
self.0.clone()
}
/// Deserializes a secp256k1 point from a cursor.
/// Deserializes an ML-KEM-768 ciphertext from a cursor.
/// Reads exactly 1088 bytes — the fixed ciphertext size for ML-KEM-768.
pub fn from_cursor(cursor: &mut Cursor<&[u8]>) -> Result<Self, NssaCoreError> {
let mut value = vec![0; 33];
let mut value = vec![0_u8; 1088];
cursor.read_exact(&mut value)?;
Ok(Self(value))
}

View File

@ -6,7 +6,7 @@ use chacha20::{
use risc0_zkvm::sha::{Impl, Sha256 as _};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey};
pub use shared_key_derivation::{EphemeralPublicKey, MlKem768EncapsulationKey, ViewingPublicKey};
use crate::{Commitment, account::Account, program::PrivateAccountKind};
#[cfg(feature = "host")]
@ -154,4 +154,41 @@ mod tests {
assert_eq!(account_ct.0.len(), pda_ct.0.len());
}
/// Verifies the full account-note pipeline: ML-KEM-768 encapsulation/decapsulation
/// feeds the correct shared secret into the SHA-256 KDF and `ChaCha20` round-trip.
#[cfg(feature = "host")]
#[test]
fn kem_to_chacha20_round_trip() {
let d = [1_u8; 32];
let z = [2_u8; 32];
let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &z);
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
let account = Account {
program_owner: [12_u32; 8],
balance: 999,
..Account::default()
};
let kind = PrivateAccountKind::Regular(0);
let commitment = crate::Commitment::new(&AccountId::new([7_u8; 32]), &account);
let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &commitment, 0);
let (decoded_kind, decoded_account) =
EncryptionScheme::decrypt(&ct, &receiver_ss, &commitment, 0)
.expect("decryption must succeed with correct shared secret");
assert_eq!(decoded_account, account);
assert_eq!(decoded_kind, kind);
// Wrong shared secret must not decrypt correctly.
let wrong_ss = SharedSecretKey([0_u8; 32]);
let bad = EncryptionScheme::decrypt(&ct, &wrong_ss, &commitment, 0);
assert!(
bad.is_none() || bad.is_some_and(|(_, a)| a.balance != 999),
"wrong shared secret must not produce the correct plaintext"
);
}
}

View File

@ -1,78 +1,232 @@
#![expect(
clippy::arithmetic_side_effects,
reason = "Multiplication of finite field elements can't overflow"
)]
use std::fmt::Write as _;
use borsh::{BorshDeserialize, BorshSerialize};
use k256::{
AffinePoint, EncodedPoint, FieldBytes, ProjectivePoint,
elliptic_curve::{
PrimeField as _,
sec1::{FromEncodedPoint as _, ToEncodedPoint as _},
},
};
use ml_kem::{Decapsulate as _, Encapsulate as _, KeyExport as _, Seed};
use serde::{Deserialize, Serialize};
use crate::{SharedSecretKey, encryption::Scalar};
use crate::SharedSecretKey;
/// The ML-KEM-768 ciphertext produced during encapsulation; transmitted on-wire in place of the
/// former ECDH ephemeral public key. Always 1088 bytes for ML-KEM-768.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct EphemeralPublicKey(pub Vec<u8>);
/// ML-KEM-768 encapsulation key bytes (1184 bytes, opaque to this crate).
#[derive(
Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, BorshSerialize, BorshDeserialize,
Serialize,
Deserialize,
Clone,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
BorshSerialize,
BorshDeserialize,
)]
pub struct Secp256k1Point(pub Vec<u8>);
pub struct MlKem768EncapsulationKey(Vec<u8>);
impl std::fmt::Debug for Secp256k1Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let hex: String = self.0.iter().fold(String::new(), |mut acc, b| {
write!(acc, "{b:02x}").expect("writing to string should not fail");
acc
});
write!(f, "Secp256k1Point({hex})")
pub type ViewingPublicKey = MlKem768EncapsulationKey;
impl MlKem768EncapsulationKey {
/// Expected byte length of an ML-KEM-768 encapsulation key.
pub const LEN: usize = 1184;
/// Construct from raw bytes, returning an error if the length is not [`Self::LEN`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, crate::error::NssaCoreError> {
if bytes.len() != Self::LEN {
return Err(crate::error::NssaCoreError::DeserializationError(format!(
"MlKem768EncapsulationKey must be {} bytes, got {}",
Self::LEN,
bytes.len()
)));
}
Ok(Self(bytes))
}
}
impl Secp256k1Point {
#[must_use]
pub fn from_scalar(value: Scalar) -> Self {
let x_bytes: FieldBytes = value.into();
let x = k256::Scalar::from_repr(x_bytes).unwrap();
let p = ProjectivePoint::GENERATOR * x;
let q = AffinePoint::from(p);
let enc = q.to_encoded_point(true);
Self(enc.as_bytes().to_vec())
pub fn to_bytes(&self) -> &[u8] {
&self.0
}
}
pub type EphemeralSecretKey = Scalar;
pub type EphemeralPublicKey = Secp256k1Point;
pub type ViewingPublicKey = Secp256k1Point;
impl From<&EphemeralSecretKey> for EphemeralPublicKey {
fn from(value: &EphemeralSecretKey) -> Self {
Self::from_scalar(*value)
/// Derive the ML-KEM-768 encapsulation key from the FIPS 203 seed halves `d` and `z`.
#[must_use]
pub fn from_seed(d: &[u8; 32], z: &[u8; 32]) -> Self {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
Self(dk.encapsulation_key().to_bytes().to_vec())
}
}
impl SharedSecretKey {
/// Creates a new shared secret key from a scalar and a point.
/// Sender: encapsulate a fresh shared secret toward `ek`.
///
/// Returns `(shared_secret, ciphertext)`. The ciphertext must be included in the transaction
/// as the `EphemeralPublicKey`; the receiver recovers the same shared secret via
/// [`Self::decapsulate`].
#[must_use]
pub fn new(scalar: Scalar, point: &Secp256k1Point) -> Self {
let scalar = k256::Scalar::from_repr(scalar.into()).unwrap();
let point: [u8; 33] = point.0.clone().try_into().unwrap();
pub fn encapsulate(ek: &MlKem768EncapsulationKey) -> (Self, EphemeralPublicKey) {
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
let (ct, ss) = ek_obj.encapsulate();
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
(Self(ss_bytes), EphemeralPublicKey(ct.to_vec()))
}
let encoded = EncodedPoint::from_bytes(point).unwrap();
let pubkey_affine = AffinePoint::from_encoded_point(&encoded).unwrap();
/// Deterministically encapsulate a shared secret toward `ek` for use in tests.
///
/// The shared secret has no secret entropy — it is fully determined by `ek`,
/// `message_hash`, and `output_index`, all of which are public. This makes it
/// unsuitable for real encryption but useful for producing stable, reproducible
/// shared secrets in unit tests. Use a distinct `output_index` per output to
/// avoid EPK collisions across multiple outputs in the same test.
///
/// For production use [`Self::encapsulate`], which draws randomness from the OS.
#[cfg(any(test, feature = "test_utils"))]
#[must_use]
pub fn encapsulate_deterministic(
ek: &MlKem768EncapsulationKey,
message_hash: &[u8; 32],
output_index: u32,
) -> (Self, EphemeralPublicKey) {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let shared = ProjectivePoint::from(pubkey_affine) * scalar;
let shared_affine = shared.to_affine();
let mut input = Vec::with_capacity(36);
input.extend_from_slice(message_hash);
input.extend_from_slice(&output_index.to_le_bytes());
let hash = Impl::hash_bytes(&input);
let m: ml_kem::B32 =
ml_kem::array::Array::try_from(hash.as_bytes()).expect("SHA-256 output is 32 bytes");
let shared_affine_encoded = shared_affine.to_encoded_point(false);
let x_bytes_slice = shared_affine_encoded.x().unwrap();
let mut x_bytes = [0_u8; 32];
x_bytes.copy_from_slice(x_bytes_slice);
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
let (ct, ss) = ek_obj.encapsulate_deterministic(&m);
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
(Self(ss_bytes), EphemeralPublicKey(ct.to_vec()))
}
Self(x_bytes)
/// Receiver: decapsulate the shared secret from a KEM ciphertext.
///
/// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on
/// the wallet scan path should skip the output rather than panic on malformed chain data.
///
/// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed.
#[must_use]
pub fn decapsulate(
ciphertext: &EphemeralPublicKey,
d: &[u8; 32],
z: &[u8; 32],
) -> Option<Self> {
let mut seed = Seed::default();
seed[..32].copy_from_slice(d);
seed[32..].copy_from_slice(z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ss = dk.decapsulate_slice(&ciphertext.0).ok()?;
let ss_bytes: [u8; 32] = ss
.as_slice()
.try_into()
.expect("ML-KEM shared key is 32 bytes");
Some(Self(ss_bytes))
}
}
#[cfg(test)]
mod tests {
use ml_kem::KeyExport as _;
use super::*;
#[test]
fn encapsulate_decapsulate_round_trip() {
let d = [1_u8; 32];
let z = [2_u8; 32];
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d);
seed[32..].copy_from_slice(&z);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
let ek_bytes = dk.encapsulation_key().to_bytes();
let ek = MlKem768EncapsulationKey(ek_bytes.to_vec());
let (sender_ss, epk) = SharedSecretKey::encapsulate(&ek);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match");
assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes");
assert_eq!(
ek.0.len(),
1184,
"ML-KEM-768 encapsulation key is 1184 bytes"
);
}
#[test]
fn decapsulate_returns_none_for_malformed_epk() {
let d = [1_u8; 32];
let z = [2_u8; 32];
// Too short — 100 bytes instead of 1088.
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(),
"short EphemeralPublicKey must return None"
);
// Too long — 1089 bytes instead of 1088.
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
assert!(
SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(),
"long EphemeralPublicKey must return None"
);
// Empty.
let empty_epk = EphemeralPublicKey(vec![]);
assert!(
SharedSecretKey::decapsulate(&empty_epk, &d, &z).is_none(),
"empty EphemeralPublicKey must return None"
);
}
#[test]
fn different_vpks_produce_different_shared_secrets() {
let (d1, z1) = ([1_u8; 32], [2_u8; 32]);
let (d2, z2) = ([3_u8; 32], [4_u8; 32]);
let ek1 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d1);
seed[32..].copy_from_slice(&z1);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec())
};
let ek2 = {
let mut seed = Seed::default();
seed[..32].copy_from_slice(&d2);
seed[32..].copy_from_slice(&z2);
let dk = ml_kem::DecapsulationKey768::from_seed(seed);
MlKem768EncapsulationKey(dk.encapsulation_key().to_bytes().to_vec())
};
let (ss1, _) = SharedSecretKey::encapsulate(&ek1);
let (ss2, _) = SharedSecretKey::encapsulate(&ek2);
assert_ne!(ss1.0, ss2.0);
}
}

View File

@ -243,8 +243,8 @@ mod tests {
let expected_sender_pre = sender.clone();
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![sender, recipient],
@ -340,11 +340,11 @@ mod tests {
Commitment::new(&recipient_account_id, &expected_private_account_2),
];
let esk_1 = [3; 32];
let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk());
let shared_secret_1 =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0;
let esk_2 = [5; 32];
let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk());
let shared_secret_2 =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1).0;
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
@ -418,8 +418,8 @@ mod tests {
))
.unwrap();
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0).0;
let program_with_deps = ProgramWithDependencies::new(
validity_window_chain_caller,
@ -449,7 +449,8 @@ mod tests {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -487,7 +488,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
@ -526,7 +528,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
@ -581,7 +584,8 @@ mod tests {
let shared_keys = test_private_account_keys_1();
let shared_npk = shared_keys.npk();
let shared_identifier: u128 = 42;
let shared_secret = SharedSecretKey::new([55; 32], &shared_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&shared_keys.vpk(), &[0_u8; 32], 0).0;
// Sender: public account with balance, owned by auth-transfer
let sender_id = AccountId::new([99; 32]);
@ -632,7 +636,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
@ -662,7 +666,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let sender = AccountWithMetadata::new(
Account {
@ -707,7 +711,7 @@ mod tests {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let account = Account {
program_owner: program.id(),
@ -756,7 +760,7 @@ mod tests {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
@ -811,7 +815,8 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -838,7 +843,7 @@ mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let ssk = SharedSecretKey::new([55; 32], &keys.vpk());
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);

View File

@ -143,7 +143,7 @@ pub mod tests {
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind,
SharedSecretKey,
account::{Account, AccountId, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
encryption::ViewingPublicKey,
program::{BlockValidityWindow, TimestampValidityWindow},
};
use sha2::{Digest as _, Sha256};
@ -208,7 +208,7 @@ pub mod tests {
let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
// all remaining vec fields are empty: u32 len=0
let empty_vec_bytes: &[u8] = &[0_u8; 4];
// validity windows: unbounded = {from: None (0u8), to: None (0u8)}
// validity windows: unbounded = {from: None (0_u8), to: None (0_u8)}
let unbounded_window_bytes: &[u8] = &[0_u8; 2];
let expected_borsh_vec: Vec<u8> = [
@ -246,13 +246,11 @@ pub mod tests {
#[test]
fn encrypted_account_data_constructor() {
let npk = NullifierPublicKey::from(&[1; 32]);
let vpk = ViewingPublicKey::from_scalar([2; 32]);
let vpk = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]);
let account = Account::default();
let account_id = nssa_core::account::AccountId::for_regular_private_account(&npk, 0);
let commitment = Commitment::new(&account_id, &account);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &vpk);
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &[0_u8; 32], 0);
let ciphertext = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(0),

View File

@ -423,7 +423,7 @@ pub mod tests {
BlockId, Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey,
NullifierSecretKey, SharedSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{
BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId,
TimestampValidityWindow, WrappedBalanceSum,
@ -538,7 +538,8 @@ pub mod tests {
pub struct TestPrivateKeys {
pub nsk: NullifierSecretKey,
pub vsk: Scalar,
pub d: [u8; 32],
pub z: [u8; 32],
}
impl TestPrivateKeys {
@ -547,7 +548,7 @@ pub mod tests {
}
pub fn vpk(&self) -> ViewingPublicKey {
ViewingPublicKey::from_scalar(self.vsk)
ViewingPublicKey::from_seed(&self.d, &self.z)
}
}
@ -1335,14 +1336,16 @@ pub mod tests {
pub fn test_private_account_keys_1() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [13; 32],
vsk: [31; 32],
d: [31; 32],
z: [32; 32],
}
}
pub fn test_private_account_keys_2() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [38; 32],
vsk: [83; 32],
d: [83; 32],
z: [84; 32],
}
}
@ -1363,9 +1366,8 @@ pub mod tests {
let recipient =
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = circuit::execute_and_prove(
vec![sender, recipient],
@ -1415,13 +1417,11 @@ pub mod tests {
let recipient_pre =
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
let esk_1 = [3; 32];
let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk());
let epk_1 = EphemeralPublicKey::from_scalar(esk_1);
let (shared_secret_1, epk_1) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let esk_2 = [3; 32];
let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk());
let epk_2 = EphemeralPublicKey::from_scalar(esk_2);
let (shared_secret_2, epk_2) =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1);
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
@ -1485,9 +1485,8 @@ pub mod tests {
*recipient_account_id,
);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
@ -1995,14 +1994,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: recipient_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2041,14 +2050,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2087,14 +2106,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2133,14 +2162,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2179,14 +2218,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2223,14 +2272,24 @@ pub mod tests {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&sender_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(
&recipient_keys.vpk(),
&[0_u8; 32],
0,
)
.0,
identifier: 0,
},
],
@ -2249,7 +2308,8 @@ pub mod tests {
let program = Program::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
@ -2291,7 +2351,8 @@ pub mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2328,7 +2389,8 @@ pub mod tests {
let npk_a = keys_a.npk();
let npk_b = keys_b.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys_b.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0;
// `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state.
// `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in
@ -2363,7 +2425,8 @@ pub mod tests {
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2402,7 +2465,8 @@ pub mod tests {
let npk = keys.npk();
let claim_seed = PdaSeed::new([77; 32]);
let wrong_delegated_seed = PdaSeed::new([88; 32]);
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
@ -2440,8 +2504,8 @@ pub mod tests {
let keys_a = test_private_account_keys_1();
let keys_b = test_private_account_keys_2();
let seed = PdaSeed::new([55; 32]);
let shared_a = SharedSecretKey::new([66; 32], &keys_a.vpk());
let shared_b = SharedSecretKey::new([77; 32], &keys_b.vpk());
let shared_a = SharedSecretKey::encapsulate_deterministic(&keys_a.vpk(), &[0_u8; 32], 0).0;
let shared_b = SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0;
let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk(), u128::MAX);
let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk(), u128::MAX);
@ -2482,7 +2546,8 @@ pub mod tests {
let program = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let seed = PdaSeed::new([99; 32]);
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized =
@ -2582,7 +2647,8 @@ pub mod tests {
(&sender_keys.npk(), 0),
);
let shared_secret = SharedSecretKey::new([55; 32], &sender_keys.vpk());
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0;
let result = execute_and_prove(
vec![private_account_1.clone(), private_account_1],
Program::serialize_instruction(100_u128).unwrap(),
@ -2926,9 +2992,8 @@ pub mod tests {
AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key));
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0);
let balance = 37;
@ -3032,13 +3097,11 @@ pub mod tests {
None,
);
let from_esk = [3; 32];
let from_ss = SharedSecretKey::new(from_esk, &from_keys.vpk());
let from_epk = EphemeralPublicKey::from_scalar(from_esk);
let (from_ss, from_epk) =
SharedSecretKey::encapsulate_deterministic(&from_keys.vpk(), &[0_u8; 32], 0);
let to_esk = [3; 32];
let to_ss = SharedSecretKey::new(to_esk, &to_keys.vpk());
let to_epk = EphemeralPublicKey::from_scalar(to_esk);
let (to_ss, to_epk) =
SharedSecretKey::encapsulate_deterministic(&to_keys.vpk(), &[0_u8; 32], 1);
let mut dependencies = HashMap::new();
@ -3335,9 +3398,8 @@ pub mod tests {
let program = Program::authenticated_transfer_program();
// Set up parameters for the new account
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let instruction = authenticated_transfer_core::Instruction::Initialize;
@ -3387,9 +3449,8 @@ pub mod tests {
AccountWithMetadata::new(Account::default(), false, (&private_keys.npk(), 0));
let program = Program::claimer();
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let (output, proof) = execute_and_prove(
vec![unauthorized_account],
@ -3437,9 +3498,8 @@ pub mod tests {
let claimer_program = Program::claimer();
// Set up parameters for claiming the new account
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0);
let instruction = authenticated_transfer_core::Instruction::Initialize;
@ -3487,8 +3547,8 @@ pub mod tests {
};
let noop_program = Program::noop();
let esk2 = [4; 32];
let shared_secret2 = SharedSecretKey::new(esk2, &private_keys.vpk());
let shared_secret2 =
SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0).0;
// Step 3: Try to execute noop program with authentication but without initialization
let res = execute_and_prove(
@ -3572,7 +3632,8 @@ pub mod tests {
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -3598,7 +3659,8 @@ pub mod tests {
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()),
ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0)
.0,
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
@ -3644,8 +3706,8 @@ pub mod tests {
let balance_to_transfer = 10_u128;
let instruction = (balance_to_transfer, auth_transfers.id());
let recipient_esk = [3; 32];
let recipient = SharedSecretKey::new(recipient_esk, &recipient_keys.vpk());
let recipient =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0;
let mut dependencies = HashMap::new();
dependencies.insert(auth_transfers.id(), auth_transfers);
@ -3801,9 +3863,8 @@ pub mod tests {
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0);
let instruction = (
block_validity_window,
@ -3871,9 +3932,8 @@ pub mod tests {
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0);
let instruction = (
BlockValidityWindow::new_unbounded(),
@ -4427,8 +4487,10 @@ pub mod tests {
..Account::default()
};
let alice_shared_0 = SharedSecretKey::new([10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new([11; 32], &alice_keys.vpk());
let (alice_shared_0, alice_epk_0) =
SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 0);
let (alice_shared_1, alice_epk_1) =
SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 1);
// Fund alice_pda_0 via authenticated_transfer directly.
{
@ -4456,11 +4518,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_0.clone())],
output,
)
.unwrap();
@ -4500,11 +4558,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_1.clone())],
output,
)
.unwrap();
@ -4551,11 +4605,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![Nonce(0)],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_0)],
output,
)
.unwrap();
@ -4596,11 +4646,7 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
vec![(alice_npk, alice_keys.vpk(), alice_epk_1)],
output,
)
.unwrap();
@ -4628,7 +4674,7 @@ pub mod tests {
};
let commitment_pda_1_after_spend =
Commitment::new(&alice_pda_1_id, &alice_pda_1_account_after_spend);
let alice_shared_1_refund = SharedSecretKey::new([12; 32], &alice_keys.vpk());
let alice_shared_1_refund = SharedSecretKey([12; 32]);
{
let recipient_account = state.get_account_by_id(recipient_id);
let recipient_nonce = recipient_account.nonce;
@ -4664,7 +4710,7 @@ pub mod tests {
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([12; 32]),
EphemeralPublicKey(vec![12_u8; 1088]),
)],
output,
)

View File

@ -544,7 +544,6 @@ mod tests {
use nssa_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
@ -571,9 +570,7 @@ mod tests {
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk());
let victim_id = AccountId::new([20_u8; 32]);
let recipient_id = AccountId::new([42_u8; 32]);
@ -695,7 +692,6 @@ mod tests {
use nssa_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
@ -725,9 +721,7 @@ mod tests {
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk());
// Victim is a private account — not registered in public chain state.
let victim_keys = test_private_account_keys_2();

View File

@ -2,10 +2,10 @@ use common::PINATA_BASE58;
use key_protocol::key_management::{
KeyChain,
key_tree::chain_index::ChainIndex,
secret_holders::{PrivateKeyHolder, SecretSpendingKey},
secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey},
};
use nssa::{Account, AccountId, Data, PrivateKey, PublicKey, V03State};
use nssa_core::{NullifierPublicKey, encryption::shared_key_derivation::Secp256k1Point};
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey};
use serde::{Deserialize, Serialize};
const PRIVATE_KEY_PUB_ACC_A: [u8; 32] = [
@ -38,24 +38,24 @@ const NSK_PRIV_ACC_B: [u8; 32] = [
23, 99, 9, 4, 177, 230, 125, 109, 91, 160, 30,
];
const VSK_PRIV_ACC_A: [u8; 32] = [
5, 85, 114, 119, 141, 187, 202, 170, 122, 253, 198, 81, 150, 8, 155, 21, 192, 65, 24, 124, 116,
98, 110, 106, 137, 90, 165, 239, 80, 13, 222, 30,
const VSK_D_PRIV_ACC_A: [u8; 32] = [
255, 250, 140, 26, 222, 223, 174, 95, 132, 108, 124, 88, 30, 247, 82, 72, 52, 70, 84, 139, 241,
187, 41, 163, 19, 231, 232, 122, 225, 55, 134, 184,
];
const VSK_PRIV_ACC_B: [u8; 32] = [
205, 32, 76, 251, 255, 236, 96, 119, 61, 111, 65, 100, 75, 218, 12, 22, 17, 170, 55, 226, 21,
154, 161, 34, 208, 74, 27, 1, 119, 13, 88, 128,
const VSK_Z_PRIV_ACC_A: [u8; 32] = [
225, 24, 98, 78, 31, 203, 175, 248, 213, 17, 133, 207, 10, 135, 132, 151, 59, 184, 5, 81, 28,
238, 137, 62, 233, 227, 99, 17, 236, 159, 244, 63,
];
const VPK_PRIV_ACC_A: [u8; 33] = [
2, 210, 206, 38, 213, 4, 182, 198, 220, 47, 93, 148, 61, 84, 148, 250, 158, 45, 8, 81, 48, 80,
46, 230, 87, 210, 47, 204, 76, 58, 214, 167, 81,
const VSK_D_PRIV_ACC_B: [u8; 32] = [
128, 85, 85, 103, 226, 218, 119, 56, 60, 252, 31, 113, 232, 215, 156, 2, 159, 247, 156, 192,
12, 178, 229, 236, 255, 120, 146, 211, 169, 117, 153, 180,
];
const VPK_PRIV_ACC_B: [u8; 33] = [
2, 79, 110, 46, 203, 29, 206, 205, 18, 86, 27, 189, 104, 103, 113, 181, 110, 53, 78, 172, 11,
171, 190, 18, 126, 214, 81, 77, 192, 154, 58, 195, 238,
const VSK_Z_PRIV_ACC_B: [u8; 32] = [
165, 80, 169, 87, 248, 88, 167, 154, 27, 67, 131, 122, 50, 130, 111, 40, 164, 180, 204, 75,
188, 140, 110, 132, 113, 133, 222, 8, 49, 123, 187, 18,
];
const NPK_PRIV_ACC_A: [u8; 32] = [
@ -136,20 +136,20 @@ pub fn initial_priv_accounts_private_keys() -> Vec<PrivateAccountPrivateInitialD
secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_A),
private_key_holder: PrivateKeyHolder {
nullifier_secret_key: NSK_PRIV_ACC_A,
viewing_secret_key: VSK_PRIV_ACC_A,
viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A),
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_A),
viewing_public_key: Secp256k1Point(VPK_PRIV_ACC_A.to_vec()),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_Z_PRIV_ACC_A),
};
let key_chain_2 = KeyChain {
secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_B),
private_key_holder: PrivateKeyHolder {
nullifier_secret_key: NSK_PRIV_ACC_B,
viewing_secret_key: VSK_PRIV_ACC_B,
viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B),
},
nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_B),
viewing_public_key: Secp256k1Point(VPK_PRIV_ACC_B.to_vec()),
viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_Z_PRIV_ACC_B),
};
vec![

View File

@ -11,6 +11,10 @@ workspace = true
[dev-dependencies]
key_protocol.workspace = true
nssa_core = { workspace = true, features = ["host"] }
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
rand = { workspace = true }
criterion.workspace = true

View File

@ -3,7 +3,7 @@
//! Measures:
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
//! - `SharedSecretKey::encapsulate` (ML-KEM-768 encapsulation, the per-recipient cost)
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
use std::time::Duration;
@ -13,10 +13,8 @@ use key_protocol::key_management::KeyChain;
use nssa_core::{
Commitment, EncryptionScheme, SharedSecretKey,
account::{Account, AccountId},
encryption::{EphemeralPublicKey, EphemeralSecretKey},
program::PrivateAccountKind,
};
use rand::{RngCore as _, rngs::OsRng};
fn bench_keychain(c: &mut Criterion) {
let mut g = c.benchmark_group("keychain");
@ -37,34 +35,22 @@ fn bench_shared_secret_key(c: &mut Criterion) {
let mut g = c.benchmark_group("shared_secret_key");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("sender_dh", |b| {
b.iter(|| {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
let _epk = EphemeralPublicKey::from(&esk);
SharedSecretKey::new(esk, &vpk)
});
g.bench_function("sender_encapsulate", |b| {
b.iter(|| SharedSecretKey::encapsulate(&vpk));
});
g.finish();
}
fn bench_encryption(c: &mut Criterion) {
// One-time setup: a fixed Account/Commitment and a SharedSecretKey to bench
// encrypt/decrypt over a representative note. ESK gen is excluded from the
// measured loop (covered by the SharedSecretKey bench above).
// encrypt/decrypt over a representative note. Encapsulation cost is covered
// by the SharedSecretKey bench above.
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
let npk = recipient_kc.nullifier_public_key;
let account = Account::default();
let account_id = AccountId::for_regular_private_account(&npk, 0);
let commitment = Commitment::new(&account_id, &account);
let shared = {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
SharedSecretKey::new(esk, &vpk)
};
let (shared, _epk) = SharedSecretKey::encapsulate(&recipient_kc.viewing_public_key);
let kind = PrivateAccountKind::Regular(0_u128);
let output_index: u32 = 0;
@ -73,7 +59,6 @@ fn bench_encryption(c: &mut Criterion) {
g.bench_function("encrypt", |b| {
b.iter(|| EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index));
});
// One ciphertext for the decrypt bench (encrypt is deterministic given inputs).
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
g.bench_function("decrypt", |b| {
b.iter(|| EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index));

View File

@ -180,6 +180,7 @@ async fn timed_token_send(
to: Some(public_mention(to_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount,
}),

View File

@ -50,6 +50,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),

View File

@ -69,6 +69,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(sender_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER * 5,
}),
@ -97,6 +98,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(*recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),

View File

@ -46,6 +46,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(private_mention(private_a)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 1_000,
}),
@ -64,6 +65,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(public_recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
}),
@ -82,6 +84,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(private_mention(private_b)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 200,
}),

View File

@ -41,6 +41,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(public_mention(recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 1_000,
}),
@ -61,6 +62,7 @@ pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
to: Some(private_mention(private_recipient_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 500,
}),

View File

@ -125,7 +125,7 @@ pub unsafe extern "C" fn wallet_ffi_get_private_account_keys(
// NPK is a 32-byte array
let npk_bytes = key_chain.nullifier_public_key.0;
// VPK is a compressed secp256k1 point (33 bytes)
// VPK is an ML-KEM-768 encapsulation key (1184 bytes)
let vpk_bytes = key_chain.viewing_public_key.to_bytes();
let vpk_len = vpk_bytes.len();
let vpk_vec = vpk_bytes.to_vec();

View File

@ -4,7 +4,6 @@ use core::slice;
use std::{ffi::c_char, ptr};
use nssa::Data;
use nssa_core::encryption::shared_key_derivation::Secp256k1Point;
use crate::error::WalletFfiError;
@ -72,9 +71,9 @@ impl Default for FfiAccount {
pub struct FfiPrivateAccountKeys {
/// Nullifier public key (32 bytes).
pub nullifier_public_key: FfiBytes32,
/// viewing public key (compressed secp256k1 point).
/// Viewing public key (ML-KEM-768 encapsulation key, 1184 bytes).
pub viewing_public_key: *const u8,
/// Length of viewing public key (typically 33 bytes).
/// Length of viewing public key (always 1184 bytes for ML-KEM-768).
pub viewing_public_key_len: usize,
}
@ -161,11 +160,15 @@ impl FfiPrivateAccountKeys {
}
pub fn vpk(&self) -> Result<nssa_core::encryption::ViewingPublicKey, WalletFfiError> {
if self.viewing_public_key_len == 33 {
if self.viewing_public_key_len == 1184 {
let slice = unsafe {
slice::from_raw_parts(self.viewing_public_key, self.viewing_public_key_len)
};
Ok(Secp256k1Point(slice.to_vec()))
Ok(
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(slice.to_vec()).expect(
"wallet_ffi::types::FfiPrivateAccountKeys::vpk: length already validated above",
),
)
} else {
Err(WalletFfiError::InvalidKeyValue)
}

View File

@ -135,11 +135,11 @@ typedef struct FfiPrivateAccountKeys {
*/
struct FfiBytes32 nullifier_public_key;
/**
* viewing public key (compressed secp256k1 point).
* Viewing public key (ML-KEM-768 encapsulation key, 1184 bytes).
*/
const uint8_t *viewing_public_key;
/**
* Length of viewing public key (typically 33 bytes).
* Length of viewing public key (always 1184 bytes for ML-KEM-768).
*/
uintptr_t viewing_public_key_len;
} FfiPrivateAccountKeys;

View File

@ -136,9 +136,9 @@ impl AccountManager {
} => {
let acc = nssa_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, (&npk, identifier));
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
let pre = AccountPreparedData {
nsk: None,
npk,
@ -165,9 +165,9 @@ impl AccountManager {
} => {
let acc = nssa_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, account_id);
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
let pre = AccountPreparedData {
nsk: None,
npk,
@ -365,9 +365,9 @@ async fn private_key_tree_acc_preparation(
// support from that in the wallet.
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id);
let eph_holder = EphemeralKeyHolder::new(&from_npk);
let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let eph_holder = EphemeralKeyHolder::new(&from_vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
Ok(AccountPreparedData {
nsk: Some(nsk),
@ -405,9 +405,10 @@ async fn private_shared_acc_preparation(
.await
.unwrap_or(None);
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
Ok(AccountPreparedData {
nsk: Some(nsk),
npk,
@ -430,7 +431,7 @@ mod tests {
let acc = AccountIdentity::PrivateShared {
nsk: [0; 32],
npk: NullifierPublicKey([1; 32]),
vpk: ViewingPublicKey::from_scalar([2; 32]),
vpk: ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]),
identifier: 42,
};
assert!(acc.is_private());

View File

@ -51,6 +51,20 @@ pub enum AccountSubcommand {
/// Import external account.
#[command(subcommand)]
Import(ImportSubcommand),
/// Print the npk and vpk for a private account, one per line.
///
/// Outputs two lines: npk (hex) then vpk (hex). Save to a file and share it
/// with senders so they can reference it with `--to-keys /path/to/file`.
///
/// ```text
/// wallet account show-keys --account-id Private/... > alice.keys
/// ```
#[command(name = "show-keys")]
ShowKeys {
/// Either 32 byte base58 account id string with privacy prefix or a label.
#[arg(long)]
account_id: CliAccountMention,
},
}
/// Represents generic register CLI subcommand.
@ -225,7 +239,7 @@ impl WalletSubcommand for NewSubcommand {
println!("Shared account from group '{group}'");
println!("AccountId: Private/{}", info.account_id);
println!("NPK: {}", hex::encode(info.npk.0));
println!("VPK: {}", hex::encode(&info.vpk.0));
println!("VPK: {}", hex::encode(info.vpk.to_bytes()));
wallet_core.store_persistent_data()?;
Ok(SubcommandReturnValue::RegisterAccount {
@ -419,6 +433,25 @@ impl WalletSubcommand for AccountSubcommand {
Self::Import(import_subcommand) => {
import_subcommand.handle_subcommand(wallet_core).await
}
Self::ShowKeys { account_id } => {
let resolved = account_id.resolve(wallet_core.storage())?;
let AccountIdWithPrivacy::Private(account_id) = resolved else {
anyhow::bail!(
"wallet::cli::account::AccountSubcommand::ShowKeys: show-keys is only available for private accounts"
);
};
let entry = wallet_core
.storage()
.key_chain()
.private_account(account_id)
.ok_or_else(|| anyhow::anyhow!("wallet::cli::account::AccountSubcommand::ShowKeys: private account not found in wallet"))?;
println!("{}", hex::encode(entry.key_chain.nullifier_public_key.0));
println!(
"{}",
hex::encode(entry.key_chain.viewing_public_key.to_bytes())
);
Ok(SubcommandReturnValue::Empty)
}
}
}
}

View File

@ -1,6 +1,9 @@
use anyhow::{Context as _, Result};
use clap::Subcommand;
use key_protocol::key_management::group_key_holder::{GroupKeyHolder, SealingPublicKey};
use key_protocol::key_management::{
group_key_holder::{GroupKeyHolder, SealingPublicKey},
secret_holders::ViewingSecretKey,
};
use crate::{
WalletCore,
@ -149,9 +152,15 @@ impl WalletSubcommand for GroupSubcommand {
anyhow::bail!("Sealing key already exists. Each wallet has one sealing key.");
}
let mut secret: nssa_core::encryption::Scalar = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret);
let public_key = SealingPublicKey::from_scalar(secret);
let mut d = [0_u8; 32];
let mut z = [0_u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut d);
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut z);
let secret = ViewingSecretKey::new(d, z);
let ek_bytes = nssa_core::encryption::ViewingPublicKey::from_seed(&d, &z)
.to_bytes()
.to_vec();
let public_key = SealingPublicKey::from_bytes(ek_bytes);
wallet_core.set_sealing_secret_key(secret);
wallet_core.store_persistent_data()?;

View File

@ -285,6 +285,31 @@ pub fn read_password_from_stdin() -> Result<String> {
Ok(password.trim().to_owned())
}
/// Parse a keys file exported by `wallet account show-keys`.
///
/// The file format is two lines:
/// - Line 1: npk as hex (64 chars, 32 bytes).
/// - Line 2: vpk as hex (2368 chars, 1184 bytes).
///
/// Returns `(npk_bytes, vpk_bytes)`.
pub fn read_keys_file(path: &str) -> Result<(Vec<u8>, Vec<u8>)> {
let content = std::fs::read_to_string(path).with_context(|| {
format!("wallet::cli::read_keys_file: failed to read keys file: {path}")
})?;
let mut lines = content.lines().filter(|l| !l.trim().is_empty());
let npk_hex = lines.next().ok_or_else(|| {
anyhow::anyhow!("wallet::cli::read_keys_file: keys file is missing npk (line 1)")
})?;
let vpk_hex = lines.next().ok_or_else(|| {
anyhow::anyhow!("wallet::cli::read_keys_file: keys file is missing vpk (line 2)")
})?;
let npk = hex::decode(npk_hex.trim())
.context("wallet::cli::read_keys_file: npk in keys file must be valid hex")?;
let vpk = hex::decode(vpk_hex.trim())
.context("wallet::cli::read_keys_file: vpk in keys file must be valid hex")?;
Ok((npk, vpk))
}
pub fn read_mnemonic_from_stdin() -> Result<Mnemonic> {
let mut phrase = String::new();
@ -328,3 +353,77 @@ pub async fn execute_keys_restoration(wallet_core: &mut WalletCore, depth: u32)
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_keys_file_roundtrip() {
let npk = [0xab_u8; 32];
let vpk = [0xcd_u8; 1184];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.keys");
// Simulate what `wallet account show-keys` writes.
std::fs::write(
&path,
format!("{}\n{}\n", hex::encode(npk), hex::encode(vpk)),
)
.unwrap();
let (parsed_npk, parsed_vpk) = read_keys_file(path.to_str().unwrap()).unwrap();
assert_eq!(parsed_npk, npk, "npk must round-trip through the keys file");
assert_eq!(
parsed_vpk,
vpk.to_vec(),
"vpk must round-trip through the keys file"
);
}
#[test]
fn read_keys_file_missing_vpk_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("incomplete.keys");
std::fs::write(&path, format!("{}\n", hex::encode([0xab_u8; 32]))).unwrap();
let result = read_keys_file(path.to_str().unwrap());
assert!(result.is_err(), "missing vpk line must return an error");
assert!(
result.unwrap_err().to_string().contains("missing vpk"),
"error must mention missing vpk"
);
}
#[test]
fn read_keys_file_invalid_hex_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("badhex.keys");
std::fs::write(&path, "not-hex\nalso-not-hex\n").unwrap();
let result = read_keys_file(path.to_str().unwrap());
assert!(result.is_err(), "invalid hex must return an error");
}
#[test]
fn read_keys_file_ignores_blank_lines() {
let npk = [0x11_u8; 32];
let vpk = [0x22_u8; 1184];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blanks.keys");
// Extra blank lines around the data should be tolerated.
std::fs::write(
&path,
format!("\n{}\n\n{}\n\n", hex::encode(npk), hex::encode(vpk)),
)
.unwrap();
let (parsed_npk, parsed_vpk) = read_keys_file(path.to_str().unwrap()).unwrap();
assert_eq!(parsed_npk, npk);
assert_eq!(parsed_vpk, vpk.to_vec());
}
}

View File

@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context as _, Result};
use clap::Subcommand;
use common::transaction::NSSATransaction;
use nssa::AccountId;
@ -34,13 +34,17 @@ pub enum AuthTransferSubcommand {
#[arg(long)]
to: Option<CliAccountMention>,
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "to_keys")]
to_npk: Option<String>,
/// `to_vpk` - valid 33 byte hex string.
#[arg(long)]
/// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long, conflicts_with = "to_keys")]
to_vpk: Option<String>,
/// Path to a keys file exported by `wallet account show-keys`, containing npk
/// and vpk on separate lines. Replaces `--to-npk` and `--to-vpk`.
#[arg(long, conflicts_with_all = ["to_npk", "to_vpk"])]
to_keys: Option<String>,
/// Identifier for the recipient's private account (only used when sending to a foreign
/// private account via `--to-npk`/`--to-vpk`).
/// private account via `--to-npk`/`--to-vpk` or `--to-keys`).
#[arg(long)]
to_identifier: Option<u128>,
/// amount - amount of balance to move.
@ -100,9 +104,18 @@ impl WalletSubcommand for AuthTransferSubcommand {
to: to_account,
to_npk,
to_vpk,
to_keys,
to_identifier,
amount,
} => {
// Resolve --to-keys into --to-npk / --to-vpk equivalents.
let (to_npk, to_vpk) = if let Some(path) = to_keys {
let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?;
(Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes)))
} else {
(to_npk, to_vpk)
};
let from = from_account.resolve(wallet_core.storage())?;
let to = to_account
.as_ref()
@ -258,7 +271,7 @@ pub enum NativeTokenTransferProgramSubcommandShielded {
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
to_npk: String,
/// `to_vpk` - valid 33 byte hex string.
/// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long)]
to_vpk: String,
/// Identifier for the recipient's private account.
@ -298,7 +311,7 @@ pub enum NativeTokenTransferProgramSubcommandPrivate {
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
to_npk: String,
/// `to_vpk` - valid 33 byte hex string.
/// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long)]
to_vpk: String,
/// Identifier for the recipient's private account.
@ -350,11 +363,11 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandPrivate {
to_npk.copy_from_slice(&to_npk_res);
let to_npk = nssa_core::NullifierPublicKey(to_npk);
let to_vpk_res = hex::decode(to_vpk)?;
let mut to_vpk = [0_u8; 33];
to_vpk.copy_from_slice(&to_vpk_res);
let to_vpk_res = hex::decode(&to_vpk)
.context("wallet::cli::programs::native_token_transfer: to_vpk must be a valid hex string")?;
let to_vpk =
nssa_core::encryption::shared_key_derivation::Secp256k1Point(to_vpk.to_vec());
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(to_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, [secret_from, _]) = NativeTokenTransfer(wallet_core)
.send_private_transfer_to_outer_account(
@ -427,11 +440,11 @@ impl WalletSubcommand for NativeTokenTransferProgramSubcommandShielded {
to_npk.copy_from_slice(&to_npk_res);
let to_npk = nssa_core::NullifierPublicKey(to_npk);
let to_vpk_res = hex::decode(to_vpk)?;
let mut to_vpk = [0_u8; 33];
to_vpk.copy_from_slice(&to_vpk_res);
let to_vpk_res = hex::decode(&to_vpk)
.context("wallet::cli::programs::native_token_transfer: to_vpk must be a valid hex string")?;
let to_vpk =
nssa_core::encryption::shared_key_derivation::Secp256k1Point(to_vpk.to_vec());
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(to_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, _) = NativeTokenTransfer(wallet_core)
.send_shielded_transfer_to_outer_account(

View File

@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context as _, Result};
use clap::Subcommand;
use common::transaction::NSSATransaction;
use nssa::AccountId;
@ -41,13 +41,17 @@ pub enum TokenProgramAgnosticSubcommand {
#[arg(long)]
to: Option<CliAccountMention>,
/// `to_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "to_keys")]
to_npk: Option<String>,
/// `to_vpk` - valid 33 byte hex string.
#[arg(long)]
/// `to_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long, conflicts_with = "to_keys")]
to_vpk: Option<String>,
/// Path to a keys file exported by `wallet account show-keys`, containing npk
/// and vpk on separate lines. Replaces `--to-npk` and `--to-vpk`.
#[arg(long, conflicts_with_all = ["to_npk", "to_vpk"])]
to_keys: Option<String>,
/// Identifier for the recipient's private account (only used when sending to a foreign
/// private account via `--to-npk`/`--to-vpk`).
/// private account via `--to-npk`/`--to-vpk` or `--to-keys`).
#[arg(long)]
to_identifier: Option<u128>,
/// amount - amount of balance to move.
@ -87,13 +91,17 @@ pub enum TokenProgramAgnosticSubcommand {
#[arg(long)]
holder: Option<CliAccountMention>,
/// `holder_npk` - valid 32 byte hex string.
#[arg(long)]
#[arg(long, conflicts_with = "holder_keys")]
holder_npk: Option<String>,
/// `to_vpk` - valid 33 byte hex string.
#[arg(long)]
/// `holder_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long, conflicts_with = "holder_keys")]
holder_vpk: Option<String>,
/// Path to a keys file exported by `wallet account show-keys`, containing npk
/// and vpk on separate lines. Replaces `--holder-npk` and `--holder-vpk`.
#[arg(long, conflicts_with_all = ["holder_npk", "holder_vpk"])]
holder_keys: Option<String>,
/// Identifier for the holder's private account (only used when minting to a foreign
/// private account via `--holder-npk`/`--holder-vpk`).
/// private account via `--holder-npk`/`--holder-vpk` or `--holder-keys`).
#[arg(long)]
holder_identifier: Option<u128>,
/// amount - amount of balance to mint.
@ -170,9 +178,17 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand {
to,
to_npk,
to_vpk,
to_keys,
to_identifier,
amount,
} => {
let (to_npk, to_vpk) = if let Some(path) = to_keys {
let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?;
(Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes)))
} else {
(to_npk, to_vpk)
};
let from = from.resolve(wallet_core.storage())?;
let to = to
.map(|account_mention| account_mention.resolve(wallet_core.storage()))
@ -309,9 +325,17 @@ impl WalletSubcommand for TokenProgramAgnosticSubcommand {
holder,
holder_npk,
holder_vpk,
holder_keys,
holder_identifier,
amount,
} => {
let (holder_npk, holder_vpk) = if let Some(path) = holder_keys {
let (npk_bytes, vpk_bytes) = crate::cli::read_keys_file(&path)?;
(Some(hex::encode(npk_bytes)), Some(hex::encode(vpk_bytes)))
} else {
(holder_npk, holder_vpk)
};
let definition = definition.resolve(wallet_core.storage())?;
let holder = holder
.map(|account_mention| account_mention.resolve(wallet_core.storage()))
@ -475,7 +499,7 @@ pub enum TokenProgramSubcommandPrivate {
/// `recipient_npk` - valid 32 byte hex string.
#[arg(long)]
recipient_npk: String,
/// `recipient_vpk` - valid 33 byte hex string.
/// `recipient_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long)]
recipient_vpk: String,
/// Identifier for the recipient's private account.
@ -569,7 +593,7 @@ pub enum TokenProgramSubcommandShielded {
/// `recipient_npk` - valid 32 byte hex string.
#[arg(long)]
recipient_npk: String,
/// `recipient_vpk` - valid 33 byte hex string.
/// `recipient_vpk` - valid hex-encoded ML-KEM-768 encapsulation key (1184 bytes).
#[arg(long)]
recipient_vpk: String,
/// Identifier for the recipient's private account.
@ -764,12 +788,12 @@ impl WalletSubcommand for TokenProgramSubcommandPrivate {
recipient_npk.copy_from_slice(&recipient_npk_res);
let recipient_npk = nssa_core::NullifierPublicKey(recipient_npk);
let recipient_vpk_res = hex::decode(recipient_vpk)?;
let mut recipient_vpk = [0_u8; 33];
recipient_vpk.copy_from_slice(&recipient_vpk_res);
let recipient_vpk = nssa_core::encryption::shared_key_derivation::Secp256k1Point(
recipient_vpk.to_vec(),
);
let recipient_vpk_res = hex::decode(&recipient_vpk).context(
"wallet::cli::programs::token: recipient_vpk must be a valid hex string",
)?;
let recipient_vpk =
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(recipient_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, [secret_sender, _]) = Token(wallet_core)
.send_transfer_transaction_private_foreign_account(
@ -876,12 +900,12 @@ impl WalletSubcommand for TokenProgramSubcommandPrivate {
holder_npk.copy_from_slice(&holder_npk_res);
let holder_npk = nssa_core::NullifierPublicKey(holder_npk);
let holder_vpk_res = hex::decode(holder_vpk)?;
let mut holder_vpk = [0_u8; 33];
holder_vpk.copy_from_slice(&holder_vpk_res);
let holder_vpk = nssa_core::encryption::shared_key_derivation::Secp256k1Point(
holder_vpk.to_vec(),
);
let holder_vpk_res = hex::decode(&holder_vpk).context(
"wallet::cli::programs::token: holder_vpk must be a valid hex string",
)?;
let holder_vpk =
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(holder_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, [secret_definition, _]) = Token(wallet_core)
.send_mint_transaction_private_foreign_account(
@ -1032,12 +1056,12 @@ impl WalletSubcommand for TokenProgramSubcommandShielded {
recipient_npk.copy_from_slice(&recipient_npk_res);
let recipient_npk = nssa_core::NullifierPublicKey(recipient_npk);
let recipient_vpk_res = hex::decode(recipient_vpk)?;
let mut recipient_vpk = [0_u8; 33];
recipient_vpk.copy_from_slice(&recipient_vpk_res);
let recipient_vpk = nssa_core::encryption::shared_key_derivation::Secp256k1Point(
recipient_vpk.to_vec(),
);
let recipient_vpk_res = hex::decode(&recipient_vpk).context(
"wallet::cli::programs::token: recipient_vpk must be a valid hex string",
)?;
let recipient_vpk =
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(recipient_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, _) = Token(wallet_core)
.send_transfer_transaction_shielded_foreign_account(
@ -1163,12 +1187,12 @@ impl WalletSubcommand for TokenProgramSubcommandShielded {
holder_npk.copy_from_slice(&holder_npk_res);
let holder_npk = nssa_core::NullifierPublicKey(holder_npk);
let holder_vpk_res = hex::decode(holder_vpk)?;
let mut holder_vpk = [0_u8; 33];
holder_vpk.copy_from_slice(&holder_vpk_res);
let holder_vpk = nssa_core::encryption::shared_key_derivation::Secp256k1Point(
holder_vpk.to_vec(),
);
let holder_vpk_res = hex::decode(&holder_vpk).context(
"wallet::cli::programs::token: holder_vpk must be a valid hex string",
)?;
let holder_vpk =
nssa_core::encryption::MlKem768EncapsulationKey::from_bytes(holder_vpk_res)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (tx_hash, _) = Token(wallet_core)
.send_mint_transaction_shielded_foreign_account(

View File

@ -269,7 +269,10 @@ impl WalletCore {
}
/// Set the wallet's dedicated sealing secret key.
pub const fn set_sealing_secret_key(&mut self, key: nssa_core::encryption::Scalar) {
pub const fn set_sealing_secret_key(
&mut self,
key: key_protocol::key_management::secret_holders::ViewingSecretKey,
) {
self.storage.key_chain_mut().set_sealing_secret_key(key);
}
@ -725,7 +728,7 @@ impl WalletCore {
.storage
.key_chain()
.private_account_key_chains()
.flat_map(|(_account_id, key_chain, index)| {
.flat_map(|(_account_id, key_chain, _index)| {
let view_tag = EncryptedAccountData::compute_view_tag(
&key_chain.nullifier_public_key,
&key_chain.viewing_public_key,
@ -740,10 +743,8 @@ impl WalletCore {
.filter_map(move |(ciph_id, encrypted_data)| {
let ciphertext = &encrypted_data.ciphertext;
let commitment = &new_commitments[ciph_id];
let shared_secret = key_chain.calculate_shared_secret_receiver(
&encrypted_data.epk,
index.and_then(ChainIndex::index),
);
let shared_secret =
key_chain.calculate_shared_secret_receiver(&encrypted_data.epk)?;
nssa_core::EncryptionScheme::decrypt(
ciphertext,
@ -825,7 +826,11 @@ impl WalletCore {
continue;
}
let shared_secret = SharedSecretKey::new(vsk, &encrypted_data.epk);
let Some(shared_secret) =
SharedSecretKey::decapsulate(&encrypted_data.epk, &vsk.d, &vsk.z)
else {
continue;
};
let commitment = &tx.message.new_commitments[ciph_id];
if let Some((_kind, new_acc)) = nssa_core::EncryptionScheme::decrypt(

View File

@ -6,7 +6,7 @@ use key_protocol::key_management::{
KeyChain,
group_key_holder::GroupKeyHolder,
key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex, traits::KeyTreeNode as _},
secret_holders::SeedHolder,
secret_holders::{SeedHolder, ViewingSecretKey},
};
use log::{debug, warn};
use nssa::{Account, AccountId};
@ -79,7 +79,7 @@ pub struct UserKeyChain {
/// Dedicated sealing secret key for GMS distribution. Generated once via
/// `wallet group new-sealing-key`. The corresponding public key is shared with
/// group members so they can seal GMS for this wallet.
sealing_secret_key: Option<nssa_core::encryption::Scalar>,
sealing_secret_key: Option<ViewingSecretKey>,
}
impl UserKeyChain {
@ -509,12 +509,12 @@ impl UserKeyChain {
/// Returns the sealing secret key for GMS distribution, if it exists.
#[must_use]
pub const fn sealing_secret_key(&self) -> Option<nssa_core::encryption::Scalar> {
self.sealing_secret_key
pub const fn sealing_secret_key(&self) -> Option<&ViewingSecretKey> {
self.sealing_secret_key.as_ref()
}
/// Sets the sealing secret key for GMS distribution.
pub const fn set_sealing_secret_key(&mut self, key: nssa_core::encryption::Scalar) {
pub const fn set_sealing_secret_key(&mut self, key: ViewingSecretKey) {
self.sealing_secret_key = Some(key);
}
@ -584,7 +584,7 @@ impl UserKeyChain {
KeyChainPersistentData {
accounts,
sealing_secret_key: *sealing_secret_key,
sealing_secret_key: sealing_secret_key.clone(),
group_key_holders: group_key_holders.clone(),
shared_private_accounts: shared_private_accounts.clone(),
}

View File

@ -5,6 +5,7 @@ use key_protocol::key_management::{
key_tree::{
chain_index::ChainIndex, keys_private::ChildKeysPrivate, keys_public::ChildKeysPublic,
},
secret_holders::ViewingSecretKey,
};
use serde::{Deserialize, Serialize};
use testnet_initial_state::{PrivateAccountPrivateInitialData, PublicAccountPrivateInitialData};
@ -26,7 +27,7 @@ pub struct PersistentStorage {
pub struct KeyChainPersistentData {
pub accounts: Vec<PersistentAccountData>,
#[serde(default)]
pub sealing_secret_key: Option<nssa_core::encryption::Scalar>,
pub sealing_secret_key: Option<ViewingSecretKey>,
#[serde(default)]
pub group_key_holders: BTreeMap<Label, GroupKeyHolder>,
#[serde(default)]