quip-signer 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quip_signer-0.2.2/PKG-INFO +9 -0
- quip_signer-0.2.2/pyproject.toml +31 -0
- quip_signer-0.2.2/python/quip_signer/__init__.py +26 -0
- quip_signer-0.2.2/python/quip_signer/__init__.pyi +28 -0
- quip_signer-0.2.2/python/quip_signer/py.typed +0 -0
- quip_signer-0.2.2/transaction-crypto-core/Cargo.toml +26 -0
- quip_signer-0.2.2/transaction-crypto-core/src/lib.rs +424 -0
- quip_signer-0.2.2/transaction-crypto-core/tests/golden_parity.rs +95 -0
- quip_signer-0.2.2/transaction-crypto-core/tests/golden_vectors.txt +18 -0
- quip_signer-0.2.2/transaction-crypto-py/.gitignore +12 -0
- quip_signer-0.2.2/transaction-crypto-py/Cargo.lock +993 -0
- quip_signer-0.2.2/transaction-crypto-py/Cargo.toml +36 -0
- quip_signer-0.2.2/transaction-crypto-py/README.md +109 -0
- quip_signer-0.2.2/transaction-crypto-py/src/lib.rs +190 -0
- quip_signer-0.2.2/transaction-crypto-py/tests/test_api.py +71 -0
- quip_signer-0.2.2/transaction-crypto-py/tests/test_parity.py +74 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quip-signer
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
+
Summary: Quip hybrid (sr25519 + ML-DSA-44) transaction signer
|
|
7
|
+
Home-Page: https://www.quip.network/
|
|
8
|
+
License: MIT-0
|
|
9
|
+
Requires-Python: >=3.9
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.7,<2"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quip-signer"
|
|
7
|
+
description = "Quip hybrid (sr25519 + ML-DSA-44) transaction signer"
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
license = { text = "MIT-0" }
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Rust",
|
|
12
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
13
|
+
]
|
|
14
|
+
# Single-sourced from Cargo.toml's `[package] version` (maturin reads it when
|
|
15
|
+
# `version` is dynamic), so a release bumps the version in exactly one place.
|
|
16
|
+
dynamic = ["version"]
|
|
17
|
+
|
|
18
|
+
[tool.maturin]
|
|
19
|
+
# Mixed layout: the Rust extension `_quip_signer` is dropped into the
|
|
20
|
+
# `quip_signer` Python package under `python/`, which re-exports it and ships
|
|
21
|
+
# type stubs. That dir also serves as the submodule/PYTHONPATH staging location
|
|
22
|
+
# (`maturin develop` drops the built `.so` there).
|
|
23
|
+
#
|
|
24
|
+
# `python-source` MUST stay inside the crate tree. `maturin build` only packs a
|
|
25
|
+
# python-source that lives under the manifest dir; an out-of-tree path (e.g.
|
|
26
|
+
# `../../py/...`) is honored by `maturin develop`/`sdist` but silently dropped
|
|
27
|
+
# from the built wheel, shipping the `.so` with no `__init__.py` (QUI-792). The
|
|
28
|
+
# wheel-content guard in scripts/python-dists.sh fails the build if it regresses.
|
|
29
|
+
module-name = "quip_signer._quip_signer"
|
|
30
|
+
manifest-path = "transaction-crypto-py/Cargo.toml"
|
|
31
|
+
python-source = "python"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Quip hybrid (sr25519 + ML-DSA-44) transaction signer.
|
|
2
|
+
|
|
3
|
+
Thin Python surface over the Rust `_quip_signer` extension, which wraps the
|
|
4
|
+
same `sp`-free core the browser WASM signer is built from. All bytes in / bytes
|
|
5
|
+
out; invalid input raises :class:`QuipSignerError`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._quip_signer import (
|
|
9
|
+
HybridSigner,
|
|
10
|
+
QuipSignerError,
|
|
11
|
+
account_id_from_public,
|
|
12
|
+
public_from_seed,
|
|
13
|
+
seed_from_mnemonic,
|
|
14
|
+
sign_payload_from_seed,
|
|
15
|
+
verify_envelope,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"HybridSigner",
|
|
20
|
+
"QuipSignerError",
|
|
21
|
+
"account_id_from_public",
|
|
22
|
+
"public_from_seed",
|
|
23
|
+
"seed_from_mnemonic",
|
|
24
|
+
"sign_payload_from_seed",
|
|
25
|
+
"verify_envelope",
|
|
26
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class QuipSignerError(Exception):
|
|
2
|
+
"""Raised when the Quip hybrid signer rejects its input."""
|
|
3
|
+
|
|
4
|
+
def public_from_seed(seed: bytes) -> bytes:
|
|
5
|
+
"""Derive serialized H3 public bytes (1344B) from a 32-byte master seed."""
|
|
6
|
+
|
|
7
|
+
def account_id_from_public(public: bytes) -> bytes:
|
|
8
|
+
"""Derive the compact 32-byte Quip account id from H3 public bytes."""
|
|
9
|
+
|
|
10
|
+
def seed_from_mnemonic(secret_uri: str) -> bytes:
|
|
11
|
+
"""Derive the 32-byte master seed from a BIP39 phrase or `0x`-hex seed URI."""
|
|
12
|
+
|
|
13
|
+
def sign_payload_from_seed(seed: bytes, payload: bytes) -> bytes:
|
|
14
|
+
"""Sign payload bytes with a 32-byte seed; return the SCALE-encoded envelope."""
|
|
15
|
+
|
|
16
|
+
def verify_envelope(payload: bytes, envelope: bytes, account_id: bytes) -> bool:
|
|
17
|
+
"""Verify a SCALE-encoded envelope against a payload and compact account id."""
|
|
18
|
+
|
|
19
|
+
class HybridSigner:
|
|
20
|
+
@staticmethod
|
|
21
|
+
def from_seed(seed: bytes) -> HybridSigner: ...
|
|
22
|
+
@staticmethod
|
|
23
|
+
def from_mnemonic(secret_uri: str) -> HybridSigner: ...
|
|
24
|
+
@property
|
|
25
|
+
def public_key(self) -> bytes: ...
|
|
26
|
+
@property
|
|
27
|
+
def account_id(self) -> bytes: ...
|
|
28
|
+
def sign(self, payload: bytes) -> bytes: ...
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "quip-transaction-crypto-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
license = "MIT-0"
|
|
5
|
+
authors = ["Quip Network <roman@postquant.xyz>"]
|
|
6
|
+
homepage = "https://www.quip.network/"
|
|
7
|
+
repository = "https://gitlab.com/piqued/quip-protocol-rs.git"
|
|
8
|
+
edition = "2021"
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
quip-crypto-primitives-core = { git = "https://github.com/QuipNetwork/polkadot-sdk.git", branch = "v0.2", default-features = false }
|
|
12
|
+
bip39 = { version = "^2.2", default-features = false }
|
|
13
|
+
blake2 = { version = "^0.10", default-features = false }
|
|
14
|
+
codec = { version = "^3.7.5", default-features = false, features = ["derive"], package = "parity-scale-codec" }
|
|
15
|
+
substrate-bip39 = { version = "^0.6.1", default-features = false }
|
|
16
|
+
zeroize = { version = "^1", default-features = false, features = ["derive"] }
|
|
17
|
+
|
|
18
|
+
[features]
|
|
19
|
+
default = ["std"]
|
|
20
|
+
std = [
|
|
21
|
+
"quip-crypto-primitives-core/std",
|
|
22
|
+
"blake2/std",
|
|
23
|
+
"codec/std",
|
|
24
|
+
"substrate-bip39/std",
|
|
25
|
+
"zeroize/std",
|
|
26
|
+
]
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#![cfg_attr(not(feature = "std"), no_std)]
|
|
2
|
+
|
|
3
|
+
//! Pure transaction/account identity helpers for Quip's hybrid signer.
|
|
4
|
+
//!
|
|
5
|
+
//! This crate is intentionally bytes-oriented:
|
|
6
|
+
//! - it derives compact 32-byte account ids from hybrid public bytes
|
|
7
|
+
//! - it signs raw payload bytes with the H3 suite
|
|
8
|
+
//! - it encodes/decodes the runtime signature envelope as raw bytes
|
|
9
|
+
//!
|
|
10
|
+
//! The H3 suite itself (the `sr25519 + ML-DSA-44` keygen / sign / verify logic,
|
|
11
|
+
//! message framing, and seed derivation) is **not** reimplemented here. It is
|
|
12
|
+
//! the shared, `sp`-free [`Sr25519MlDsa44`] engine from
|
|
13
|
+
//! `quip-crypto-primitives-core`, which the runtime is also built from — so the
|
|
14
|
+
//! browser signer and the runtime verifier are byte-identical by construction.
|
|
15
|
+
//!
|
|
16
|
+
//! What stays quip-specific and lives here: account-id derivation
|
|
17
|
+
//! (`quip-account-v1`), the SCALE transaction-signature envelope, and the
|
|
18
|
+
//! BIP39 / secret-URI → master-seed helpers. None of those exist in the shared
|
|
19
|
+
//! crate.
|
|
20
|
+
//!
|
|
21
|
+
//! It does not depend on runtime traits, `sp_core`, or `sp_io`.
|
|
22
|
+
|
|
23
|
+
extern crate alloc;
|
|
24
|
+
|
|
25
|
+
use alloc::vec::Vec;
|
|
26
|
+
|
|
27
|
+
use bip39::{Language, Mnemonic};
|
|
28
|
+
use blake2::digest::{Update, VariableOutput};
|
|
29
|
+
use blake2::Blake2bVar;
|
|
30
|
+
use codec::{Decode, DecodeWithMemTracking, Encode};
|
|
31
|
+
use quip_crypto_primitives_core::suite::sr25519_mldsa44::{
|
|
32
|
+
Sr25519MlDsa44, HYBRID_PK_LEN, HYBRID_SIG_LEN, HYBRID_SK_LEN,
|
|
33
|
+
};
|
|
34
|
+
use quip_crypto_primitives_core::{HybridSignatureError, HybridSignatureScheme};
|
|
35
|
+
use zeroize::Zeroize;
|
|
36
|
+
|
|
37
|
+
const MASTER_SEED_LEN: usize = 32;
|
|
38
|
+
|
|
39
|
+
/// Serialized H3 public-key length in bytes.
|
|
40
|
+
pub const HYBRID_PUBLIC_LEN: usize = HYBRID_PK_LEN;
|
|
41
|
+
/// Serialized H3 signature length in bytes.
|
|
42
|
+
pub const HYBRID_SIGNATURE_LEN: usize = HYBRID_SIG_LEN;
|
|
43
|
+
/// Serialized H3 secret-key length in bytes.
|
|
44
|
+
pub const HYBRID_SECRET_LEN: usize = HYBRID_SK_LEN;
|
|
45
|
+
/// Fixed length of derived Quip account ids.
|
|
46
|
+
pub const ACCOUNT_ID_LEN: usize = 32;
|
|
47
|
+
|
|
48
|
+
/// Domain separator for account-id derivation from the hybrid public key.
|
|
49
|
+
pub const ACCOUNT_ID_DOMAIN: &[u8] = b"quip-account-v1";
|
|
50
|
+
|
|
51
|
+
/// Error returned by byte-level hybrid transaction crypto helpers.
|
|
52
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
53
|
+
pub enum HybridTxCryptoError {
|
|
54
|
+
InvalidLength {
|
|
55
|
+
expected: usize,
|
|
56
|
+
actual: usize,
|
|
57
|
+
},
|
|
58
|
+
InvalidPublicKey,
|
|
59
|
+
InvalidSecretKey,
|
|
60
|
+
InvalidSeed,
|
|
61
|
+
SigningFailed,
|
|
62
|
+
/// The BIP39 phrase was not a valid English mnemonic.
|
|
63
|
+
InvalidMnemonic,
|
|
64
|
+
/// The secret URI contained `/`-prefixed derivation junctions, which the
|
|
65
|
+
/// browser signer intentionally does not support.
|
|
66
|
+
UnsupportedDerivationPath,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub type HybridResult<T> = core::result::Result<T, HybridTxCryptoError>;
|
|
70
|
+
|
|
71
|
+
/// Maps the shared suite's error type onto this crate's public error type, so
|
|
72
|
+
/// the boundary error surface is preserved across the dependency swap.
|
|
73
|
+
fn map_suite_error(error: HybridSignatureError) -> HybridTxCryptoError {
|
|
74
|
+
match error {
|
|
75
|
+
HybridSignatureError::InvalidLength { expected, actual } => {
|
|
76
|
+
HybridTxCryptoError::InvalidLength { expected, actual }
|
|
77
|
+
}
|
|
78
|
+
HybridSignatureError::InvalidSeedLength { expected, actual } => {
|
|
79
|
+
HybridTxCryptoError::InvalidLength { expected, actual }
|
|
80
|
+
}
|
|
81
|
+
HybridSignatureError::InvalidPublicKey => HybridTxCryptoError::InvalidPublicKey,
|
|
82
|
+
HybridSignatureError::InvalidSecretKey => HybridTxCryptoError::InvalidSecretKey,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Bytes-level transaction signature envelope.
|
|
87
|
+
#[derive(Clone, Debug, Eq, PartialEq, Hash, Encode, Decode, DecodeWithMemTracking)]
|
|
88
|
+
pub struct HybridTxSignatureBytes {
|
|
89
|
+
pub public: [u8; HYBRID_PUBLIC_LEN],
|
|
90
|
+
pub signature: [u8; HYBRID_SIGNATURE_LEN],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
impl HybridTxSignatureBytes {
|
|
94
|
+
/// Creates a bytes-level envelope after validating the input lengths and encodings.
|
|
95
|
+
pub fn new(public: &[u8], signature: &[u8]) -> HybridResult<Self> {
|
|
96
|
+
let public_key = Sr25519MlDsa44::public_key_from_bytes(public).map_err(map_suite_error)?;
|
|
97
|
+
let signature_value =
|
|
98
|
+
Sr25519MlDsa44::signature_from_bytes(signature).map_err(map_suite_error)?;
|
|
99
|
+
|
|
100
|
+
Ok(Self {
|
|
101
|
+
public: public_key.to_bytes(),
|
|
102
|
+
signature: signature_value.to_bytes(),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Returns the derived compact account id for the embedded public key.
|
|
107
|
+
pub fn derived_account_id(&self) -> [u8; ACCOUNT_ID_LEN] {
|
|
108
|
+
account_id_from_public_bytes(&self.public)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Verifies the embedded signature against the provided raw message bytes.
|
|
112
|
+
pub fn verify(&self, message: &[u8]) -> bool {
|
|
113
|
+
let Ok(public) = Sr25519MlDsa44::public_key_from_bytes(&self.public) else {
|
|
114
|
+
return false;
|
|
115
|
+
};
|
|
116
|
+
let Ok(signature) = Sr25519MlDsa44::signature_from_bytes(&self.signature) else {
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
Sr25519MlDsa44::verify(&public, message, b"", &signature)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// SCALE-encodes the bytes-level envelope.
|
|
123
|
+
pub fn encode_envelope(&self) -> Vec<u8> {
|
|
124
|
+
self.encode()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Decodes a SCALE-encoded bytes-level envelope.
|
|
128
|
+
pub fn decode_envelope(bytes: &[u8]) -> HybridResult<Self> {
|
|
129
|
+
let decoded =
|
|
130
|
+
Self::decode(&mut &bytes[..]).map_err(|_| HybridTxCryptoError::InvalidLength {
|
|
131
|
+
expected: HYBRID_PUBLIC_LEN + HYBRID_SIGNATURE_LEN,
|
|
132
|
+
actual: bytes.len(),
|
|
133
|
+
})?;
|
|
134
|
+
Self::new(&decoded.public, &decoded.signature)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Derives the compact Quip account id from serialized H3 public bytes.
|
|
139
|
+
pub fn account_id_from_public_bytes(public: &[u8]) -> [u8; ACCOUNT_ID_LEN] {
|
|
140
|
+
let mut hasher = Blake2bVar::new(ACCOUNT_ID_LEN).expect("32-byte Blake2 output is valid");
|
|
141
|
+
hasher.update(ACCOUNT_ID_DOMAIN);
|
|
142
|
+
hasher.update(public);
|
|
143
|
+
|
|
144
|
+
let mut out = [0u8; ACCOUNT_ID_LEN];
|
|
145
|
+
hasher
|
|
146
|
+
.finalize_variable(&mut out)
|
|
147
|
+
.expect("output length matches buffer");
|
|
148
|
+
out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Derives serialized H3 public bytes from a 32-byte H3 master seed.
|
|
152
|
+
pub fn public_key_from_seed(seed: &[u8]) -> HybridResult<[u8; HYBRID_PUBLIC_LEN]> {
|
|
153
|
+
let (_secret, public) = Sr25519MlDsa44::from_seed_slice(seed).map_err(map_suite_error)?;
|
|
154
|
+
Ok(public.to_bytes())
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Derives the 32-byte H3 master seed from a limited secret URI.
|
|
158
|
+
///
|
|
159
|
+
/// This mirrors the supported subset of substrate's `Pair::from_string`:
|
|
160
|
+
///
|
|
161
|
+
/// - a `0x`-prefixed 64-digit hex string is used directly as the master seed;
|
|
162
|
+
/// - otherwise the input is an English BIP39 phrase, optionally followed by
|
|
163
|
+
/// `///<password>`.
|
|
164
|
+
///
|
|
165
|
+
/// Derivation junctions (`//hard`, `/soft`) and the leading-`/` dev-phrase
|
|
166
|
+
/// shortcut are intentionally **not** supported: a `/` outside the `///`
|
|
167
|
+
/// password separator is rejected rather than silently ignored, so an imported
|
|
168
|
+
/// account can never derive to an address the runtime would not recognize.
|
|
169
|
+
pub fn master_seed_from_secret_uri(uri: &str) -> HybridResult<[u8; MASTER_SEED_LEN]> {
|
|
170
|
+
let (phrase, password) = match uri.split_once("///") {
|
|
171
|
+
Some((phrase, password)) => (phrase, Some(password)),
|
|
172
|
+
None => (uri, None),
|
|
173
|
+
};
|
|
174
|
+
let phrase = phrase.trim();
|
|
175
|
+
|
|
176
|
+
if let Some(hex) = phrase
|
|
177
|
+
.strip_prefix("0x")
|
|
178
|
+
.or_else(|| phrase.strip_prefix("0X"))
|
|
179
|
+
{
|
|
180
|
+
return decode_seed_hex(hex);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if phrase.contains('/') {
|
|
184
|
+
return Err(HybridTxCryptoError::UnsupportedDerivationPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
master_seed_from_mnemonic(phrase, password)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Derives the 32-byte H3 master seed from an English BIP39 phrase.
|
|
191
|
+
///
|
|
192
|
+
/// This matches substrate's `Pair::from_phrase`: the mnemonic entropy is run
|
|
193
|
+
/// through `substrate_bip39::seed_from_entropy` (PBKDF2-HMAC-SHA512, salt
|
|
194
|
+
/// `"mnemonic" || password`, 2048 rounds) and the first 32 bytes of the 64-byte
|
|
195
|
+
/// output are taken as the master seed.
|
|
196
|
+
pub fn master_seed_from_mnemonic(
|
|
197
|
+
phrase: &str,
|
|
198
|
+
password: Option<&str>,
|
|
199
|
+
) -> HybridResult<[u8; MASTER_SEED_LEN]> {
|
|
200
|
+
// `parse_in_normalized` expects already-NFKD input and, unlike `parse_in`,
|
|
201
|
+
// does not pull in `unicode-normalization`. English BIP39 words are
|
|
202
|
+
// lowercase ASCII (always NFKD) and NFKD never changes case, so this yields
|
|
203
|
+
// the same entropy as substrate's `parse_in` for every valid English
|
|
204
|
+
// mnemonic.
|
|
205
|
+
let mnemonic = Mnemonic::parse_in_normalized(Language::English, phrase)
|
|
206
|
+
.map_err(|_| HybridTxCryptoError::InvalidMnemonic)?;
|
|
207
|
+
let (entropy, entropy_len) = mnemonic.to_entropy_array();
|
|
208
|
+
|
|
209
|
+
let mut big_seed =
|
|
210
|
+
substrate_bip39::seed_from_entropy(&entropy[..entropy_len], password.unwrap_or(""))
|
|
211
|
+
.map_err(|_| HybridTxCryptoError::InvalidMnemonic)?;
|
|
212
|
+
|
|
213
|
+
let mut seed = [0u8; MASTER_SEED_LEN];
|
|
214
|
+
seed.copy_from_slice(&big_seed[..MASTER_SEED_LEN]);
|
|
215
|
+
big_seed.zeroize();
|
|
216
|
+
|
|
217
|
+
Ok(seed)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Decodes a 64-digit hex string into a 32-byte master seed.
|
|
221
|
+
fn decode_seed_hex(hex: &str) -> HybridResult<[u8; MASTER_SEED_LEN]> {
|
|
222
|
+
if hex.len() != MASTER_SEED_LEN * 2 {
|
|
223
|
+
return Err(HybridTxCryptoError::InvalidSeed);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let mut seed = [0u8; MASTER_SEED_LEN];
|
|
227
|
+
for (index, byte) in seed.iter_mut().enumerate() {
|
|
228
|
+
let hi = hex_digit(hex.as_bytes()[index * 2])?;
|
|
229
|
+
let lo = hex_digit(hex.as_bytes()[index * 2 + 1])?;
|
|
230
|
+
*byte = (hi << 4) | lo;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
Ok(seed)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fn hex_digit(ch: u8) -> HybridResult<u8> {
|
|
237
|
+
match ch {
|
|
238
|
+
b'0'..=b'9' => Ok(ch - b'0'),
|
|
239
|
+
b'a'..=b'f' => Ok(ch - b'a' + 10),
|
|
240
|
+
b'A'..=b'F' => Ok(ch - b'A' + 10),
|
|
241
|
+
_ => Err(HybridTxCryptoError::InvalidSeed),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Signs raw payload bytes with a 32-byte H3 master seed and returns the bytes-level envelope.
|
|
246
|
+
///
|
|
247
|
+
/// # Payload contract
|
|
248
|
+
///
|
|
249
|
+
/// `payload` is signed **exactly as given** — this function performs no hashing
|
|
250
|
+
/// and no length check. Two caller obligations follow:
|
|
251
|
+
///
|
|
252
|
+
/// - **H3 domain prefix is intrinsic; do NOT pre-apply it.** The H3 scheme
|
|
253
|
+
/// (`Sr25519MlDsa44`) frames every message internally as
|
|
254
|
+
/// `0x01 ‖ "hybrid-sr25519-mldsa44-v1\0" ‖ len(ctx) ‖ ctx ‖ msg` before
|
|
255
|
+
/// hashing/signing. Browser, runtime, and Python all go through the same
|
|
256
|
+
/// core, so they agree byte-for-byte. Callers pass the unframed payload;
|
|
257
|
+
/// pre-applying the prefix yourself would double-frame and the runtime would
|
|
258
|
+
/// reject the signature.
|
|
259
|
+
/// - **The Substrate >256-byte rule is the caller's job.** Substrate signs
|
|
260
|
+
/// `SignedPayload::using_encoded`, which substitutes `blake2_256(payload)`
|
|
261
|
+
/// for the raw bytes whenever the SCALE-encoded payload exceeds 256 bytes.
|
|
262
|
+
/// That is an extrinsic convention, not part of H3, so this function does not
|
|
263
|
+
/// apply it. A caller that hands a >256-byte extrinsic payload here verbatim
|
|
264
|
+
/// gets a signature the runtime silently rejects. Hash first, then sign the
|
|
265
|
+
/// 32-byte digest.
|
|
266
|
+
pub fn sign_payload_from_seed(seed: &[u8], payload: &[u8]) -> HybridResult<HybridTxSignatureBytes> {
|
|
267
|
+
let (secret, public) = Sr25519MlDsa44::from_seed_slice(seed).map_err(map_suite_error)?;
|
|
268
|
+
let signature = Sr25519MlDsa44::sign_deterministic(&secret, payload, b"", b"");
|
|
269
|
+
|
|
270
|
+
HybridTxSignatureBytes::new(public.as_ref(), signature.as_ref())
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Signs raw payload bytes with expanded H3 secret bytes and matching public bytes.
|
|
274
|
+
///
|
|
275
|
+
/// The payload contract is identical to [`sign_payload_from_seed`]: the bytes
|
|
276
|
+
/// are signed verbatim (no hashing, no length check), the H3 domain prefix is
|
|
277
|
+
/// applied intrinsically by the scheme, and applying Substrate's >256-byte
|
|
278
|
+
/// `blake2_256` rule is the caller's responsibility.
|
|
279
|
+
pub fn sign_payload_from_secret(
|
|
280
|
+
secret: &[u8],
|
|
281
|
+
public: &[u8],
|
|
282
|
+
payload: &[u8],
|
|
283
|
+
) -> HybridResult<HybridTxSignatureBytes> {
|
|
284
|
+
let secret_key = Sr25519MlDsa44::secret_key_from_bytes(secret).map_err(map_suite_error)?;
|
|
285
|
+
let signature = Sr25519MlDsa44::sign_deterministic(&secret_key, payload, b"", b"");
|
|
286
|
+
|
|
287
|
+
HybridTxSignatureBytes::new(public, signature.as_ref())
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#[cfg(test)]
|
|
291
|
+
mod tests {
|
|
292
|
+
use super::*;
|
|
293
|
+
|
|
294
|
+
#[test]
|
|
295
|
+
fn account_id_domain_is_pinned() {
|
|
296
|
+
assert_eq!(ACCOUNT_ID_DOMAIN, b"quip-account-v1");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[test]
|
|
300
|
+
fn same_public_key_derives_same_account_id() {
|
|
301
|
+
let public = public_key_from_seed(&[1u8; 32]).unwrap();
|
|
302
|
+
let first = account_id_from_public_bytes(public.as_ref());
|
|
303
|
+
let second = account_id_from_public_bytes(public.as_ref());
|
|
304
|
+
|
|
305
|
+
assert_eq!(first, second);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn different_public_keys_derive_different_account_ids() {
|
|
310
|
+
let alice = public_key_from_seed(&[1u8; 32]).unwrap();
|
|
311
|
+
let bob = public_key_from_seed(&[2u8; 32]).unwrap();
|
|
312
|
+
|
|
313
|
+
assert_ne!(
|
|
314
|
+
account_id_from_public_bytes(alice.as_ref()),
|
|
315
|
+
account_id_from_public_bytes(bob.as_ref())
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn sign_and_verify_roundtrip() {
|
|
321
|
+
let envelope = sign_payload_from_seed(&[7u8; 32], b"quip-message").unwrap();
|
|
322
|
+
assert!(envelope.verify(b"quip-message"));
|
|
323
|
+
assert!(!envelope.verify(b"wrong-message"));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#[test]
|
|
327
|
+
fn public_key_from_seed_matches_signed_envelope_public() {
|
|
328
|
+
let public = public_key_from_seed(&[7u8; 32]).unwrap();
|
|
329
|
+
let envelope = sign_payload_from_seed(&[7u8; 32], b"quip-message").unwrap();
|
|
330
|
+
|
|
331
|
+
assert_eq!(public, envelope.public);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[test]
|
|
335
|
+
fn envelope_round_trips_through_scale() {
|
|
336
|
+
let envelope = sign_payload_from_seed(&[9u8; 32], b"quip-message").unwrap();
|
|
337
|
+
let encoded = envelope.encode_envelope();
|
|
338
|
+
let decoded = HybridTxSignatureBytes::decode_envelope(&encoded).unwrap();
|
|
339
|
+
|
|
340
|
+
assert_eq!(decoded, envelope);
|
|
341
|
+
assert!(decoded.verify(b"quip-message"));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn envelope_decode_rejects_empty_bytes() {
|
|
346
|
+
assert!(HybridTxSignatureBytes::decode_envelope(&[]).is_err());
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#[test]
|
|
350
|
+
fn tampered_signature_rejects() {
|
|
351
|
+
let mut envelope = sign_payload_from_seed(&[11u8; 32], b"quip-message").unwrap();
|
|
352
|
+
envelope.signature[envelope.signature.len() / 2] ^= 0xFF;
|
|
353
|
+
|
|
354
|
+
assert!(!envelope.verify(b"quip-message"));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#[test]
|
|
358
|
+
fn wrong_seed_length_rejects() {
|
|
359
|
+
assert!(public_key_from_seed(&[1u8; 31]).is_err());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Standard BIP39 test vector phrase.
|
|
363
|
+
const TEST_PHRASE: &str =
|
|
364
|
+
"bottom drive obey lake curtain smoke basket hold race lonely fit walk";
|
|
365
|
+
|
|
366
|
+
#[test]
|
|
367
|
+
fn mnemonic_seed_is_deterministic() {
|
|
368
|
+
let first = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
369
|
+
let second = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
370
|
+
|
|
371
|
+
assert_eq!(first, second);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn mnemonic_password_changes_seed() {
|
|
376
|
+
let no_password = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
377
|
+
let with_password = master_seed_from_mnemonic(TEST_PHRASE, Some("hunter2")).unwrap();
|
|
378
|
+
|
|
379
|
+
assert_ne!(no_password, with_password);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[test]
|
|
383
|
+
fn secret_uri_password_matches_explicit_password() {
|
|
384
|
+
let split =
|
|
385
|
+
master_seed_from_secret_uri(&alloc::format!("{TEST_PHRASE}///hunter2")).unwrap();
|
|
386
|
+
let explicit = master_seed_from_mnemonic(TEST_PHRASE, Some("hunter2")).unwrap();
|
|
387
|
+
|
|
388
|
+
assert_eq!(split, explicit);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#[test]
|
|
392
|
+
fn secret_uri_accepts_hex_seed() {
|
|
393
|
+
let seed = master_seed_from_secret_uri(
|
|
394
|
+
"0x0707070707070707070707070707070707070707070707070707070707070707",
|
|
395
|
+
)
|
|
396
|
+
.unwrap();
|
|
397
|
+
|
|
398
|
+
assert_eq!(seed, [7u8; MASTER_SEED_LEN]);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn secret_uri_rejects_derivation_junctions() {
|
|
403
|
+
assert_eq!(
|
|
404
|
+
master_seed_from_secret_uri(&alloc::format!("{TEST_PHRASE}//0")),
|
|
405
|
+
Err(HybridTxCryptoError::UnsupportedDerivationPath)
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#[test]
|
|
410
|
+
fn secret_uri_rejects_invalid_phrase() {
|
|
411
|
+
assert_eq!(
|
|
412
|
+
master_seed_from_secret_uri("not a real mnemonic phrase at all"),
|
|
413
|
+
Err(HybridTxCryptoError::InvalidMnemonic)
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[test]
|
|
418
|
+
fn mnemonic_seed_signs_and_verifies() {
|
|
419
|
+
let seed = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
420
|
+
let envelope = sign_payload_from_seed(&seed, b"quip-message").unwrap();
|
|
421
|
+
|
|
422
|
+
assert!(envelope.verify(b"quip-message"));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//! Golden-vector parity gate.
|
|
2
|
+
//!
|
|
3
|
+
//! `golden_vectors.txt` was captured from the signer's byte output **before**
|
|
4
|
+
//! the hybrid-crypto deduplication refactor (the move of the H3 suite into the
|
|
5
|
+
//! shared `quip-crypto-primitives-core` crate). This test asserts the current
|
|
6
|
+
//! implementation still reproduces those exact bytes, so the suite that the
|
|
7
|
+
//! browser signs with and the suite the runtime verifies with can never
|
|
8
|
+
//! silently drift apart.
|
|
9
|
+
//!
|
|
10
|
+
//! Vectors cover `seed -> public_key` and `(seed, msg) -> signature envelope`
|
|
11
|
+
//! for several fixed seeds plus a BIP39-derived seed.
|
|
12
|
+
|
|
13
|
+
use quip_transaction_crypto_core::{
|
|
14
|
+
master_seed_from_mnemonic, public_key_from_seed, sign_payload_from_seed,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const FIXTURE: &str = include_str!("golden_vectors.txt");
|
|
18
|
+
|
|
19
|
+
const TEST_PHRASE: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
|
|
20
|
+
|
|
21
|
+
fn lookup<'a>(key: &str) -> &'a str {
|
|
22
|
+
for line in FIXTURE.lines() {
|
|
23
|
+
let line = line.trim();
|
|
24
|
+
if line.is_empty() {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let (name, value) = line.split_once('=').expect("fixture line is `name=value`");
|
|
28
|
+
if name == key {
|
|
29
|
+
// Leak-free: FIXTURE is 'static, so the slice is too.
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
panic!("fixture missing key `{key}`");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn to_hex(bytes: &[u8]) -> String {
|
|
37
|
+
let mut out = String::with_capacity(bytes.len() * 2);
|
|
38
|
+
for b in bytes {
|
|
39
|
+
out.push_str(&format!("{b:02x}"));
|
|
40
|
+
}
|
|
41
|
+
out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fn assert_public(name: &str, seed: &[u8]) {
|
|
45
|
+
// Sanity: the recorded seed matches what we feed in.
|
|
46
|
+
assert_eq!(
|
|
47
|
+
to_hex(seed),
|
|
48
|
+
lookup(&format!("{name}_seed")),
|
|
49
|
+
"{name}: seed bytes drifted from fixture"
|
|
50
|
+
);
|
|
51
|
+
let public = public_key_from_seed(seed).expect("public derivation");
|
|
52
|
+
assert_eq!(
|
|
53
|
+
to_hex(&public),
|
|
54
|
+
lookup(&format!("{name}_public")),
|
|
55
|
+
"{name}: public key drifted from pre-refactor bytes"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fn assert_envelope(seed_name: &str, seed: &[u8], msg_name: &str, msg: &[u8]) {
|
|
60
|
+
let envelope = sign_payload_from_seed(seed, msg).expect("signing");
|
|
61
|
+
assert_eq!(
|
|
62
|
+
to_hex(&envelope.encode_envelope()),
|
|
63
|
+
lookup(&format!("{seed_name}_{msg_name}_envelope")),
|
|
64
|
+
"{seed_name}/{msg_name}: signature envelope drifted from pre-refactor bytes"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[test]
|
|
69
|
+
fn public_keys_match_pre_refactor_baseline() {
|
|
70
|
+
assert_public("seed_01", &[1u8; 32]);
|
|
71
|
+
assert_public("seed_07", &[7u8; 32]);
|
|
72
|
+
assert_public("seed_09", &[9u8; 32]);
|
|
73
|
+
assert_public("seed_11", &[11u8; 32]);
|
|
74
|
+
|
|
75
|
+
let bip39 = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
76
|
+
assert_public("bip39", &bip39);
|
|
77
|
+
|
|
78
|
+
let bip39_pw = master_seed_from_mnemonic(TEST_PHRASE, Some("hunter2")).unwrap();
|
|
79
|
+
assert_public("bip39_pw", &bip39_pw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[test]
|
|
83
|
+
fn signature_envelopes_match_pre_refactor_baseline() {
|
|
84
|
+
let messages: [(&str, &[u8]); 3] = [
|
|
85
|
+
("msg_quip", b"quip-message"),
|
|
86
|
+
("msg_empty", b""),
|
|
87
|
+
("msg_fixture", b"quip-signer-fixture"),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
let bip39 = master_seed_from_mnemonic(TEST_PHRASE, None).unwrap();
|
|
91
|
+
for (mname, msg) in &messages {
|
|
92
|
+
assert_envelope("seed_07", &[7u8; 32], mname, msg);
|
|
93
|
+
assert_envelope("bip39", &bip39, mname, msg);
|
|
94
|
+
}
|
|
95
|
+
}
|