Skip to content

Commit 7fce79b

Browse files
authored
Merge pull request #172 from PortalTechnologiesInc/wheatley/bip340-subkey-derivation
feat(subkey): BIP340-style tagged hash + canonical binary serialization
2 parents 69e32bf + dadd913 commit 7fce79b

File tree

1 file changed

+75
-12
lines changed

1 file changed

+75
-12
lines changed

crates/portal/src/protocol/subkey.rs

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ pub trait PublicSubkeyVerifier {
6767
) -> Result<(), SubkeyError>;
6868
}
6969

70+
/// Metadata bound to a subkey at derivation time.
71+
///
72+
/// A subkey is derived from the master key using key tweaking (same math as BIP32 non-hardened,
73+
/// but using a BIP340-style tagged hash of this metadata as the tweak instead of a chaincode):
74+
///
75+
/// ```text
76+
/// child_secret = master_secret + SHA256_tagged("Portal/Subkey", canonical_bytes(metadata)) (mod n)
77+
/// child_pubkey = master_pubkey + tweak·G
78+
/// ```
79+
///
80+
/// This lets anyone with the master *public* key verify that a given subkey is legitimately
81+
/// derived — no private key is required on the server side.
82+
///
83+
/// See [`SubkeyMetadata::canonical_bytes`] for the exact wire format and [`SubkeyMetadata::get_tweak`]
84+
/// for the tagged hash construction.
7085
#[derive(Debug, Clone, Serialize, Deserialize)]
7186
#[cfg_attr(feature = "bindings", derive(uniffi::Record))]
7287
pub struct SubkeyMetadata {
@@ -79,19 +94,70 @@ pub struct SubkeyMetadata {
7994
}
8095

8196
impl SubkeyMetadata {
82-
pub fn get_tweak(&self) -> Result<Scalar, SubkeyError> {
83-
// Serialize metadata to bytes
84-
let metadata_bytes = serde_json::to_vec(self)?;
97+
/// Produces a deterministic, cross-language binary encoding of the metadata.
98+
///
99+
/// Layout (all integers little-endian):
100+
/// ```text
101+
/// version (1 byte, u8)
102+
/// name_len (2 bytes, u16 LE)
103+
/// name (name_len bytes, UTF-8)
104+
/// nonce (32 bytes)
105+
/// valid_from (8 bytes, u64 LE, Unix timestamp)
106+
/// expires_at (8 bytes, u64 LE, Unix timestamp)
107+
/// permissions (1 byte bitmask: Auth=0x01, Payment=0x02)
108+
/// ```
109+
///
110+
/// This encoding is used as the message in [`get_tweak`] and must be reproduced
111+
/// identically by any verifier (mobile app, server) regardless of language or platform.
112+
pub fn canonical_bytes(&self) -> Vec<u8> {
113+
let mut buf = Vec::new();
114+
buf.push(self.version);
115+
let name_bytes = self.name.as_bytes();
116+
buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
117+
buf.extend_from_slice(name_bytes);
118+
buf.extend_from_slice(self.nonce.as_bytes());
119+
buf.extend_from_slice(&self.valid_from.as_u64().to_le_bytes());
120+
buf.extend_from_slice(&self.expires_at.as_u64().to_le_bytes());
121+
let mut perms: u8 = 0;
122+
for p in &self.permissions {
123+
match p {
124+
SubkeyPermission::Auth => perms |= 0x01,
125+
SubkeyPermission::Payment => perms |= 0x02,
126+
}
127+
}
128+
buf.push(perms);
129+
buf
130+
}
85131

86-
// Compute the tweaking factor by hashing the metadata
132+
/// Computes the key tweak using BIP340-style tagged hashing (same construction as BIP341 Taproot).
133+
///
134+
/// ```text
135+
/// tag = "Portal/Subkey"
136+
/// tag_hash = SHA256(tag)
137+
/// tweak = SHA256(tag_hash || tag_hash || canonical_bytes())
138+
/// ```
139+
///
140+
/// The double-prefix `tag_hash || tag_hash` is the BIP340 tagged hash pattern — it provides
141+
/// domain separation so tweaks produced here cannot collide with those from other protocols.
142+
///
143+
/// The resulting scalar is used to tweak both private and public keys:
144+
/// - Private: `child_secret = master_secret + tweak (mod n)`
145+
/// - Public: `child_pubkey = master_pubkey + tweak·G`
146+
///
147+
/// This allows the server to verify a subkey using only the master *public* key —
148+
/// no private key is ever needed on the server side.
149+
pub fn get_tweak(&self) -> Result<Scalar, SubkeyError> {
150+
let tag = b"Portal/Subkey";
151+
let tag_hash: [u8; 32] = Sha256::digest(tag).into();
87152
let mut hasher = Sha256::new();
88-
hasher.update(&metadata_bytes);
153+
hasher.update(&tag_hash);
154+
hasher.update(&tag_hash);
155+
hasher.update(&self.canonical_bytes());
89156
let hash: [u8; 32] = hasher
90157
.finalize()
91158
.try_into()
92159
.map_err(|_| SubkeyError::InvalidMetadata)?;
93-
let tweak = Scalar::from_be_bytes(hash).map_err(|_| SubkeyError::InvalidMetadata)?;
94-
Ok(tweak)
160+
Scalar::from_be_bytes(hash).map_err(|_| SubkeyError::InvalidMetadata)
95161
}
96162
}
97163

@@ -108,9 +174,6 @@ pub enum SubkeyError {
108174
#[error("Invalid metadata")]
109175
InvalidMetadata,
110176

111-
#[error("Serialization error: {0}")]
112-
Serialization(#[from] serde_json::Error),
113-
114177
#[error("Secp256k1 error: {0}")]
115178
Secp256k1(#[from] nostr::secp256k1::Error),
116179

@@ -187,7 +250,7 @@ mod tests {
187250
valid_from: Timestamp::new(valid_from),
188251
expires_at: Timestamp::new(expires_at),
189252
permissions,
190-
version: 1,
253+
version: 2,
191254
}
192255
}
193256

@@ -202,7 +265,7 @@ mod tests {
202265

203266
// Test that we can access both the metadata and the key methods
204267
assert_eq!(subkey.metadata().name, "test_subkey");
205-
assert_eq!(subkey.metadata().version, 1);
268+
assert_eq!(subkey.metadata().version, 2);
206269

207270
// Test that Deref works and we can use Keys methods directly
208271
let pubkey = subkey.public_key();

0 commit comments

Comments
 (0)