Skip to content

Commit d54d9d1

Browse files
committed
refactor(core): move subkey_seed into governance, removing a core->a2a edge
`TokenSigner::derived_subkey_seed` reached out of governance and INTO the A2A plane to call `a2a::sign::subkey_seed`. The function it was reaching for has no A2A semantics: it is SHA-256("busbar/subkey/v1" || secret || domain) over a caller-supplied domain string. It mentions no plane, and it lived on one by accident of history. Every other plane that ever asks busbar's root secret for a subkey would have had to depend on A2A to get one. So it moves to governance, beside the root secret it derives from. The direction was already established and correct in the other direction: `a2a::sign::CardSigner::derived_from` calls the governance method, and still does. Net effect is one fewer production core->a2a edge. THE BYTES DO NOT CHANGE, and that is the whole risk of this move. This derives KEY MATERIAL: if the output shifted for any input, existing deployments would break their card signatures silently, against what external callers already pinned. The function body is moved verbatim, and six known-answer vectors captured from the implementation BEFORE the move now pin it -- independently reproduced as plain SHA-256 outside this codebase, so they pin the construction rather than whatever the code happens to emit. They were confirmed to fail against a mutated context string before being accepted as green. Visibility NARROWS rather than widens: the free function was pub(crate) on the A2A plane and is private here, since its only caller is the method directly above it. Nothing was widened to accommodate the move.
1 parent 616329c commit d54d9d1

3 files changed

Lines changed: 105 additions & 15 deletions

File tree

crates/busbar-core/src/a2a/sign.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
use base64::Engine as _;
5656
use ed25519_dalek::{Signer, SigningKey};
5757
use serde_json::{json, Map, Value};
58-
use sha2::{Digest, Sha256};
5958

6059
use super::canonical::canonicalize;
6160
use super::card::{signing_payload, CardError};
@@ -180,19 +179,6 @@ impl CardSigner {
180179
}
181180
}
182181

183-
/// The domain-separated derivation itself, as a free function so it can be asserted on directly.
184-
///
185-
/// `SHA-256(context ‖ secret ‖ domain)`. The secret is a FIXED 32 bytes and sits in the middle, so
186-
/// the boundary between it and the domain string is unambiguous without a length prefix — two
187-
/// different domains cannot produce one pre-image by moving the boundary.
188-
pub(crate) fn subkey_seed(secret: &[u8; 32], domain: &str) -> [u8; 32] {
189-
let mut h = Sha256::new();
190-
h.update(b"busbar/subkey/v1");
191-
h.update(secret);
192-
h.update(domain.as_bytes());
193-
h.finalize().into()
194-
}
195-
196182
#[cfg(test)]
197183
#[path = "tests/sign_tests.rs"]
198184
mod sign_tests;

crates/busbar-core/src/governance/signing.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2323
use base64::Engine;
2424
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
2525
use serde::{Deserialize, Serialize};
26+
use sha2::{Digest, Sha256};
2627

2728
/// The token prefix, so a busbar key is visually distinct from an opaque bearer and a quick
2829
/// structural pre-check can reject an obviously-non-busbar credential before any crypto.
@@ -178,7 +179,7 @@ impl TokenSigner {
178179
/// The secret NEVER leaves this method: callers get 32 derived bytes, not
179180
/// [`Self::secret_bytes`].
180181
pub(crate) fn derived_subkey_seed(&self, domain: &str) -> [u8; 32] {
181-
crate::a2a::sign::subkey_seed(&self.key.to_bytes(), domain)
182+
subkey_seed(&self.key.to_bytes(), domain)
182183
}
183184

184185
/// Mint a signed token for `sub` expiring at `exp` (Unix seconds), stamped with the binding
@@ -317,6 +318,26 @@ impl TokenVerifier {
317318
}
318319
}
319320

321+
/// The domain-separated derivation itself, as a free function so it can be asserted on directly.
322+
///
323+
/// `SHA-256(context ‖ secret ‖ domain)`. The secret is a FIXED 32 bytes and sits in the middle, so
324+
/// the boundary between it and the domain string is unambiguous without a length prefix — two
325+
/// different domains cannot produce one pre-image by moving the boundary.
326+
///
327+
/// This is generic busbar KEY HYGIENE, not the property of any one plane. It lives here, beside the
328+
/// root secret it derives from, because the planes that ask for a subkey are its CALLERS: a
329+
/// derivation that lived on one of them would make every other plane's subkey a dependency on that
330+
/// plane, for a function whose body mentions none of them. The context string is versioned, so
331+
/// changing this derivation is a new key rather than a silently different one under the same name —
332+
/// which is also why the bytes it emits are pinned by known-answer vectors in the tests below.
333+
fn subkey_seed(secret: &[u8; 32], domain: &str) -> [u8; 32] {
334+
let mut h = Sha256::new();
335+
h.update(b"busbar/subkey/v1");
336+
h.update(secret);
337+
h.update(domain.as_bytes());
338+
h.finalize().into()
339+
}
340+
320341
#[cfg(test)]
321342
#[path = "tests/signing_tests.rs"]
322343
mod tests;

crates/busbar-core/src/governance/tests/signing_tests.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,86 @@ fn signer_debug_redacts_key() {
206206
assert!(dbg.contains("redacted"));
207207
assert!(!dbg.contains(&hex::encode([7u8; 32])));
208208
}
209+
210+
// ── THE SUBKEY DERIVATION: KNOWN-ANSWER VECTORS ──────────────────────────────────────────────────
211+
212+
/// THE BYTES ARE FROZEN. `subkey_seed` derives KEY MATERIAL, so a change to it is not a refactor
213+
/// with a test to update — it silently rotates every subkey in every existing deployment, and the
214+
/// symptom is signatures that stop verifying against what external callers already pinned. No
215+
/// property test can catch that (a different-but-still-deterministic, still-domain-separated
216+
/// derivation passes every property the neighbouring tests assert), so the bytes themselves are
217+
/// pinned here.
218+
///
219+
/// These vectors were captured from the implementation as it stood BEFORE the function moved out of
220+
/// the A2A plane into governance, and independently reproduced as
221+
/// `SHA-256("busbar/subkey/v1" ‖ secret ‖ domain)` outside this codebase — so they pin the
222+
/// construction, not merely whatever the current code happens to emit.
223+
///
224+
/// If one of these fails, do NOT re-baseline it. The context string is versioned precisely so that
225+
/// an intended change to the derivation is spelled as a new version, leaving these vectors intact.
226+
#[test]
227+
fn subkey_seed_derives_the_exact_pinned_bytes() {
228+
for (secret, domain, expected) in [
229+
(
230+
[0u8; 32],
231+
"",
232+
"0b1c0a2d5c88dd84043ada4280656292b0b97260f89b9c099deebdae4dc24a6b",
233+
),
234+
(
235+
[0u8; 32],
236+
"a2a/agent-card-signing/v1",
237+
"0bf68ad180162cd1cef2d93c0714a0bbc8584f6039fb39a4690589d11e82ecb5",
238+
),
239+
(
240+
[7u8; 32],
241+
"a2a/agent-card-signing/v1",
242+
"9e3cc179e80446fc289701e3f0996a2305eedf7747bc082053f417f22bfbc14b",
243+
),
244+
(
245+
[7u8; 32],
246+
"some/other/purpose/v1",
247+
"c7e527e9bd89ac13380f2853fbebf85eed87f4bba9f8d76302d91268abd68fec",
248+
),
249+
(
250+
[0xffu8; 32],
251+
"",
252+
"77bf96146778130445d006dea40df5f2e76e92ed2f78df2b7d7ea167f7be51b1",
253+
),
254+
] {
255+
assert_eq!(
256+
hex::encode(subkey_seed(&secret, domain)),
257+
expected,
258+
"the derived subkey seed CHANGED for domain {domain:?} — every deployment's subkeys \
259+
just rotated silently"
260+
);
261+
}
262+
263+
// A non-ASCII domain, pinned so the UTF-8 encoding of the domain string is part of the frozen
264+
// construction rather than an implementation detail free to change under it.
265+
let mut counting = [0u8; 32];
266+
for (i, b) in counting.iter_mut().enumerate() {
267+
*b = i as u8;
268+
}
269+
assert_eq!(
270+
hex::encode(subkey_seed(&counting, "dømain/✓/v1")),
271+
"a8d12c9765387d05e5147563df692d7b66b582e26d8879be61a74db65cad6bd2"
272+
);
273+
}
274+
275+
/// The method on the signer is the free function over the signer's own secret — the seam the A2A
276+
/// plane's `CardSigner` actually calls. Pinned end-to-end so the move cannot have changed what a
277+
/// caller holding a TokenSigner gets, only where the code lives.
278+
#[test]
279+
fn derived_subkey_seed_is_the_free_function_over_the_root_secret() {
280+
let s = signer(); // secret [7u8; 32]
281+
assert_eq!(
282+
hex::encode(s.derived_subkey_seed("a2a/agent-card-signing/v1")),
283+
"9e3cc179e80446fc289701e3f0996a2305eedf7747bc082053f417f22bfbc14b",
284+
"the A2A card key derived off this signer must be byte-identical across the move"
285+
);
286+
assert_eq!(
287+
s.derived_subkey_seed("a2a/agent-card-signing/v1"),
288+
subkey_seed(&s.secret_bytes(), "a2a/agent-card-signing/v1"),
289+
"the method must be exactly the free function over the root secret, with no second path"
290+
);
291+
}

0 commit comments

Comments
 (0)