Skip to content

Commit 66bb2fd

Browse files
feat(keys): derive per-profile X25519 sealing keypair (PROFILE_SEALING_X25519_LABEL)
Add the deterministic per-profile X25519 sealing keypair to dig-account — the key the DIG App uses to seal/unseal DIGCHAT1 messages (#1931, §NC-1). dig-app cannot derive this itself (the master seed is pub(crate) and never leaves this crate), so it lives here beside the DEK and wallet-key derivations. The 32-byte input keying material comes from dig-session's frozen profile_derive_symmetric_key bound to the dig-constants PROFILE_SEALING_X25519_LABEL — the same HKDF seam as the DEK, differing only in the info label (domain separation). The output is clamped to an X25519 scalar via StaticSecret::from; the keypair is derived, never stored, so a restored profile reproduces it identically (§5.1 permanence). Golden KAT pins the derivation forever. - dig-constants 0.7 -> 0.9 (adds PROFILE_SEALING_X25519_LABEL) - add x25519-dalek 2 (static_secrets) - bump 0.2.0 -> 0.3.0 (minor/additive) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 47b8c51 commit 66bb2fd

6 files changed

Lines changed: 255 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 67 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "dig-account"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
rust-version = "1.81"
66
description = "The DIG Network user Account: the fat, strictly-logical crate for everything an account does — the Account+Profile object model, unlock policy + keystore crypto, the in-process identity+money signer, per-profile key/DEK derivation, DID+dig-store mint/sign, and all wallet ops. Headless (no UI). Consumed by dig-app."
@@ -22,8 +22,9 @@ dig-wallet-backend = { version = "0.16", default-features = false, features = ["
2222
dig-session = "0.5"
2323
dig-identity = "0.5"
2424
dig-keystore = "0.4.1"
25-
dig-constants = "0.7"
25+
dig-constants = "0.9"
2626
dig-ipc-protocol = "0.3"
27+
x25519-dalek = { version = "2", features = ["static_secrets"] }
2728
chia-bls = "0.26"
2829
chia-protocol = "0.26"
2930
chia-puzzle-types = "0.26"

SPEC.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ The raw `WalletKey::secret_key()` is `pub(crate)` (§8).
110110
NOT reimplement the KDF locally, since it is the at-rest byte contract every already-sealed profile blob
111111
was encrypted under. Golden (seed = all `0x11`, ix 0): `3285f675…f543`.
112112

113+
### 3.4 Per-profile X25519 sealing keypair — HKDF via the canonical sealing label
114+
115+
`profile_sealing_secret(seed, ix)` / `profile_sealing_public_key(seed, ix)` derive the per-profile
116+
X25519 **sealing** keypair — the key the DIG App uses to seal/unseal `DIGCHAT1` messages (§NC-1
117+
end-to-end encryption). The 32-byte input keying material MUST come from `dig-session`'s frozen
118+
`profile_derive_symmetric_key(ix, PROFILE_SEALING_X25519_LABEL)`
119+
(`HKDF-SHA256(salt = DEK_SALT, ikm = IDENTITY_IKM_VERSION || scalar, info =
120+
PROFILE_SEALING_X25519_LABEL)`, `info = "dig-app:profile-sealing-x25519:v1"`) — the SAME seam as the
121+
DEK, differing ONLY in the `info` label, which is what domain-separates the sealing key from the DEK.
122+
It MUST NOT reimplement the KDF. The 32 output bytes become the X25519 secret via
123+
`StaticSecret::from(ikm)`; X25519 clamps to a valid scalar during scalar multiplication, and the
124+
public key is `PublicKey::from(&secret)`. The keypair is DERIVED, never stored: a profile restored from
125+
its recovery phrase on any other device reproduces the identical sealing keypair, so every `DIGCHAT1`
126+
message ever sealed to it stays openable forever (this is the §5.1 permanence guarantee for sealed
127+
messages). dig-account exposes ONLY the keypair; the `DIGCHAT1` envelope + attest/seal/unseal routing
128+
live in dig-app. Golden KAT (entropy = all `0x42`, ix 0, public key):
129+
`93f1556d839a6bf56930b8a3f895ac95c34b289b3cbf55e47a78de06858bfb00`.
130+
113131
## 4. Unlock policy & AccountSession lifecycle
114132

115133
### 4.1 Locked → unlock → Unlocked; idle-relock; explicit lock()

src/keys/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
//! deterministically derived from the account master seed at a profile index.
33
44
pub mod dek;
5+
pub mod sealing;
56
pub mod wallet_key;

src/keys/sealing.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//! Per-profile X25519 **sealing** keypair derivation.
2+
//!
3+
//! This is the key the DIG App uses to seal/unseal `DIGCHAT1` messages for dig-chat (§NC-1
4+
//! end-to-end encryption). It is deterministically DERIVED from the account master seed — never
5+
//! stored — so a profile restored on another device reproduces the identical sealing keypair, and
6+
//! every message a peer ever sealed to it stays openable forever (§5.1 permanence).
7+
//!
8+
//! dig-app cannot derive this itself: the master seed is `pub(crate)` and never leaves this crate.
9+
//! So the derivation lives here, beside the DEK and wallet-key derivations, and dig-account exposes
10+
//! ONLY the keypair — the `DIGCHAT1` envelope + attest/seal/unseal routing are dig-app's job.
11+
12+
use dig_session::UnlockedMasterSeed;
13+
use x25519_dalek::{PublicKey, StaticSecret};
14+
15+
use crate::id::ProfileIx;
16+
17+
/// Derive the per-profile X25519 sealing **secret** for profile `ix` from `seed`.
18+
///
19+
/// The 32-byte input keying material is produced by `dig-session`'s frozen HKDF construction bound
20+
/// to the `dig-constants`
21+
/// [`PROFILE_SEALING_X25519_LABEL`](dig_constants::PROFILE_SEALING_X25519_LABEL) — the SAME
22+
/// `profile_derive_symmetric_key` seam the DEK uses, differing only in the `info` label, which is
23+
/// what domain-separates the sealing key from the DEK. This MUST NOT reimplement the KDF.
24+
///
25+
/// [`StaticSecret::from`] stores the 32 bytes verbatim; X25519 clamps them to a valid scalar during
26+
/// the scalar multiplication (both in [`PublicKey::from`] and in Diffie–Hellman), so the resulting
27+
/// keypair is well-defined. This crate owns the clamp step (the `dig-constants` label crate owns
28+
/// only the frozen label bytes).
29+
pub fn profile_sealing_secret(seed: &UnlockedMasterSeed, ix: ProfileIx) -> StaticSecret {
30+
let ikm = seed.profile_derive_symmetric_key(ix.0, dig_constants::PROFILE_SEALING_X25519_LABEL);
31+
StaticSecret::from(*ikm)
32+
}
33+
34+
/// Derive the per-profile X25519 sealing **public** key (32 bytes) for profile `ix` from `seed`.
35+
///
36+
/// This is the public half peers seal `DIGCHAT1` messages TO. It corresponds to
37+
/// [`profile_sealing_secret`] via `PublicKey::from(&secret)`.
38+
pub fn profile_sealing_public_key(seed: &UnlockedMasterSeed, ix: ProfileIx) -> [u8; 32] {
39+
PublicKey::from(&profile_sealing_secret(seed, ix)).to_bytes()
40+
}
41+
42+
#[cfg(test)]
43+
mod tests {
44+
use super::*;
45+
use crate::keys::dek::profile_dek;
46+
use dig_keystore::{BackendKey, MemoryBackend};
47+
use dig_session::{Password, Session, ENTROPY_LEN};
48+
use std::sync::Arc;
49+
50+
/// All-`0x42` entropy — the fixture the sealing-key golden is pinned against.
51+
const SEED: [u8; ENTROPY_LEN] = [0x42; ENTROPY_LEN];
52+
53+
/// GOLDEN KAT — the per-profile X25519 sealing PUBLIC key for the all-`0x42` entropy at the
54+
/// default (ROOT) profile, pinned byte-for-byte. This FREEZES the sealing-key derivation
55+
/// forever (§5.1): the full chain `HKDF-SHA256(salt = DEK_SALT, ikm = IDENTITY_IKM_VERSION ||
56+
/// identity_scalar, info = PROFILE_SEALING_X25519_LABEL)` → clamp → X25519 basepoint mul. A
57+
/// wrong label, a dropped clamp, or a `dig-constants`/`dig-session` version change moves this
58+
/// literal — which is exactly the guard, since a changed sealing key makes every already-sealed
59+
/// `DIGCHAT1` message to this profile permanently unopenable.
60+
const GOLDEN_SEALING_PK0: [u8; 32] =
61+
hex32("93f1556d839a6bf56930b8a3f895ac95c34b289b3cbf55e47a78de06858bfb00");
62+
63+
/// Compile-time hex → 32-byte array (no dev-dependency for a fixture).
64+
const fn hex32(s: &str) -> [u8; 32] {
65+
let bytes = s.as_bytes();
66+
assert!(bytes.len() == 64, "hex length mismatch");
67+
let mut out = [0u8; 32];
68+
let mut i = 0;
69+
while i < 32 {
70+
out[i] = nibble(bytes[i * 2]) << 4 | nibble(bytes[i * 2 + 1]);
71+
i += 1;
72+
}
73+
out
74+
}
75+
const fn nibble(c: u8) -> u8 {
76+
match c {
77+
b'0'..=b'9' => c - b'0',
78+
b'a'..=b'f' => c - b'a' + 10,
79+
_ => panic!("bad hex nibble"),
80+
}
81+
}
82+
83+
fn unlocked_seed() -> UnlockedMasterSeed {
84+
Session::enroll_master_seed(
85+
Arc::new(MemoryBackend::new()),
86+
BackendKey::new("k".to_string()),
87+
Password::new("pw"),
88+
&SEED,
89+
)
90+
.unwrap()
91+
}
92+
93+
#[test]
94+
fn sealing_public_key_matches_the_pinned_golden_vector() {
95+
let seed = unlocked_seed();
96+
assert_eq!(
97+
profile_sealing_public_key(&seed, ProfileIx::ROOT),
98+
GOLDEN_SEALING_PK0,
99+
"sealing key drifted from the frozen §5.1 contract — every sealed DIGCHAT1 message \
100+
to this profile would become unopenable"
101+
);
102+
}
103+
104+
#[test]
105+
fn derivation_is_deterministic() {
106+
let seed = unlocked_seed();
107+
assert_eq!(
108+
profile_sealing_public_key(&seed, ProfileIx::ROOT),
109+
profile_sealing_public_key(&seed, ProfileIx::ROOT),
110+
"same seed + index must derive the same sealing key"
111+
);
112+
}
113+
114+
#[test]
115+
fn distinct_indices_derive_distinct_sealing_keys() {
116+
let seed = unlocked_seed();
117+
let root = profile_sealing_public_key(&seed, ProfileIx::ROOT);
118+
let one = profile_sealing_public_key(&seed, ProfileIx(1));
119+
let four = profile_sealing_public_key(&seed, ProfileIx(4));
120+
assert_ne!(root, one);
121+
assert_ne!(root, four);
122+
assert_ne!(one, four);
123+
}
124+
125+
#[test]
126+
fn public_key_corresponds_to_the_secret_key() {
127+
let seed = unlocked_seed();
128+
let secret = profile_sealing_secret(&seed, ProfileIx(3));
129+
assert_eq!(
130+
PublicKey::from(&secret).to_bytes(),
131+
profile_sealing_public_key(&seed, ProfileIx(3)),
132+
);
133+
}
134+
135+
#[test]
136+
fn sealing_ikm_is_domain_separated_from_the_dek() {
137+
// The sealing key and the DEK share the SAME seed, salt, and ikm-version — they differ ONLY
138+
// in the HKDF `info` label. Assert the underlying 32-byte keying material actually differs,
139+
// so a future collapse of the two labels (which would reuse one secret for both purposes) is
140+
// caught. Compares the pre-clamp ikm, not the clamped pubkey, to pin the domain separation
141+
// at its source.
142+
let seed = unlocked_seed();
143+
let sealing_ikm =
144+
*seed.profile_derive_symmetric_key(0, dig_constants::PROFILE_SEALING_X25519_LABEL);
145+
let dek = profile_dek(&seed, ProfileIx::ROOT);
146+
assert_ne!(
147+
sealing_ikm, dek,
148+
"sealing ikm must be domain-separated from the DEK (distinct HKDF info labels)"
149+
);
150+
}
151+
}

src/unlocked.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use zeroize::Zeroizing;
1212

1313
use crate::id::{AccountId, ProfileIx};
1414
use crate::keys::dek::profile_dek;
15+
use crate::keys::sealing::{profile_sealing_public_key, profile_sealing_secret};
1516
use crate::signer::ProfileSigner;
1617
use crate::wallet::authorizer::WalletOps;
1718

@@ -75,6 +76,20 @@ impl UnlockedAccount {
7576
profile_dek(&self.seed, ix)
7677
}
7778

79+
/// The per-profile X25519 **sealing secret** for profile `ix` — the private half the DIG App
80+
/// uses to unseal `DIGCHAT1` messages, derived from the seed via the frozen `dig-constants`
81+
/// profile-sealing contract. Deterministic, so a profile restored on another device reproduces
82+
/// the identical key and keeps every message ever sealed to it openable (§5.1).
83+
pub fn profile_sealing_key(&self, ix: ProfileIx) -> x25519_dalek::StaticSecret {
84+
profile_sealing_secret(&self.seed, ix)
85+
}
86+
87+
/// The per-profile X25519 **sealing public key** (32 bytes) for profile `ix` — the public half
88+
/// peers seal `DIGCHAT1` messages TO. Corresponds to [`profile_sealing_key`](Self::profile_sealing_key).
89+
pub fn profile_sealing_public_key(&self, ix: ProfileIx) -> [u8; 32] {
90+
profile_sealing_public_key(&self.seed, ix)
91+
}
92+
7893
/// The 24-word BIP-39 recovery phrase for this account.
7994
///
8095
/// Takes `&self`: showing a user their phrase must not cost them their session, so the account

0 commit comments

Comments
 (0)