Skip to content

Commit 7072e37

Browse files
alex-poorclaude
andcommitted
A grant can be asked to reach back only so far
`Vault::grant` has carried a warning since it was written: "A GRANT REACHES BACK OVER EVERYTHING, AND CANNOT BE ASKED NOT TO … there is no history flag and no way to pass a narrower bundle: the library does not expose one." The first half is true. The second was not, and nobody had checked, because D29 ended by saying so out loud: "This is read from the source. Nothing has been built, compiled or measured, and on the evidence of this repository's last three days that distinction is the whole difference between a finding and a result." Running it found both halves of that sentence. **The conclusion holds.** A time-scoped grant is reachable on pinned p2panda-encryption 0.7.1, no fork, no waiting on upstream. **The proposed route does not.** D29 said drop to `Dcgka::add`, which takes the bundle as a parameter. It does — but rebuilding what `EncryptionGroup::add` does around it needs `process_local`, and that is private. The table of what is public lists four things and misses the one that is not. The way through is a layer higher. `GroupState::secrets` is a public field and `update_secrets` a public setter, so the bundle a joiner receives is whatever is in that field when `add` runs. Narrow it, add, put it back. `scoped_grant.rs` asserts what is actually claimed rather than a proxy for it: the scoped reader opens the day after its cutoff, is refused the day before it (`Skipped::not_ours == 1`), and the subject still reads its own history. An unscoped grant is the control and still hands over both days. Two limits the source reading did not surface, now in the doc comment: * `GroupSecret`'s timestamp is UNIX seconds stamped at creation, not a property of the data — so this scopes by when the secret was minted, and "the last 90 days" only means that if the subject rotates on a schedule. Without rotation it is a no-op. `Vault::rotate` is added for that. * Two secrets minted in the same second cannot be told apart by any filter over them. Second granularity is the floor. It is not a revocation: a reader already holding an older secret keeps it. This narrows what a new grant hands over. This unblocks the gateway half of D29 — "90 days" can now mean 90 days. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5b76adc commit 7072e37

3 files changed

Lines changed: 307 additions & 12 deletions

File tree

‎crates/diaswarm-keys/src/lib.rs‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ pub use p2panda_encryption::key_bundle::LongTermKeyBundle;
138138
use p2panda_encryption::crypto::x25519::SecretKey;
139139
use p2panda_encryption::crypto::xchacha20::XAeadNonce;
140140
use p2panda_encryption::data_scheme::{
141-
EncryptionGroup, GroupState, GroupSecretId, decrypt_data, encrypt_data,
141+
EncryptionGroup, GroupState, GroupSecretId, SecretBundle, decrypt_data, encrypt_data,
142142
};
143143
use p2panda_encryption::key_bundle::Lifetime;
144144
use p2panda_encryption::key_manager::{KeyManager, KeyManagerState};
@@ -837,12 +837,18 @@ impl Vault {
837837
/// returned tag is what the subject's own private book should file them
838838
/// under — the grant itself names nobody.
839839
///
840-
/// ⚠️ **A GRANT REACHES BACK OVER EVERYTHING, AND CANNOT BE ASKED NOT TO.**
840+
/// ⚠️ **THIS GRANT REACHES BACK OVER EVERYTHING.**
841841
/// `EncryptionGroup::add` hands the joiner `&y.secrets` — the whole secret
842842
/// bundle — so a reader granted today opens every day the subject still
843843
/// holds a secret for, including days sealed long before they were granted.
844-
/// There is no `history` flag and no way to pass a narrower bundle: the
845-
/// library does not expose one.
844+
///
845+
/// **"AND CANNOT BE ASKED NOT TO" USED TO FOLLOW, AND IT WAS WRONG.** This
846+
/// said "there is no `history` flag and no way to pass a narrower bundle:
847+
/// the library does not expose one." There is no flag, true. But
848+
/// `GroupState::secrets` is a public field and `update_secrets` a public
849+
/// setter, so the bundle handed over is whatever is in that field when
850+
/// `add` runs — see [`Vault::grant_since`], which narrows it and is tested.
851+
/// The claim stood unchallenged until somebody ran the spike D29 asked for.
846852
///
847853
/// **THAT IS LESS THAN BOTH VAULTS THIS REPLACES.** `diaswarm-core` wraps
848854
/// per segment, so the subject chooses what a reader can open;
@@ -869,7 +875,7 @@ impl Vault {
869875
bundle: LongTermKeyBundle,
870876
purpose: &str,
871877
) -> Result<(Message, GrantTag), Error> {
872-
self.grant_inner(bundle, purpose, false)
878+
self.grant_inner(bundle, purpose, false, None)
873879
}
874880

875881
/// Grant without anybody watching — for a handover, and nothing else.
@@ -890,14 +896,65 @@ impl Vault {
890896
bundle: LongTermKeyBundle,
891897
purpose: &str,
892898
) -> Result<(Message, GrantTag), Error> {
893-
self.grant_inner(bundle, purpose, true)
899+
self.grant_inner(bundle, purpose, true, None)
900+
}
901+
902+
/// Grant a reader only the secrets created at or after `since`.
903+
///
904+
/// **THIS IS THE THING [`Vault::grant`]'s warning says cannot be done, and
905+
/// it turns out it can.** That comment reads "there is no `history` flag and
906+
/// no way to pass a narrower bundle: the library does not expose one." The
907+
/// first half is true and the second is not. `EncryptionGroup::add` hands
908+
/// the joiner `&y.secrets`, but `y.secrets` is a public field and
909+
/// `EncryptionGroup::update_secrets` is a public setter, so the bundle the
910+
/// joiner receives is whatever is in that field at the moment `add` runs.
911+
/// Narrow it, add, put it back.
912+
///
913+
/// No fork, no private API, no waiting on upstream — which is what D29
914+
/// predicted, though not by the route it proposed: dropping to `Dcgka::add`
915+
/// does not work, because reassembling what `EncryptionGroup::add` does
916+
/// afterwards needs `process_local`, and that *is* private.
917+
///
918+
/// ⚠️ **`since` IS UNIX SECONDS, AND IT FILTERS BY WHEN THE SECRET WAS
919+
/// MADE — NOT BY WHAT IT COVERS.** `GroupSecret`'s timestamp is stamped
920+
/// from `SystemTime::now()` at creation. So this scopes to rotation
921+
/// boundaries, and "the last 90 days" means "everything sealed since the
922+
/// secret that was current 90 days ago" — which is only the same sentence
923+
/// if the subject rotates on a schedule. Rotating daily makes them agree to
924+
/// the day; not rotating at all makes this a no-op, because one secret
925+
/// covers everything.
926+
///
927+
/// ⚠️ **AND IT IS NOT A REVOCATION.** A reader who already holds an older
928+
/// secret keeps it. This narrows what a *new* grant hands over; it takes
929+
/// nothing back. [`Vault::revoke`] is what bites, and it bites forward.
930+
pub fn grant_since(
931+
&mut self,
932+
bundle: LongTermKeyBundle,
933+
purpose: &str,
934+
since: u64,
935+
) -> Result<(Message, GrantTag), Error> {
936+
self.grant_inner(bundle, purpose, false, Some(since))
937+
}
938+
939+
/// Rotate the group secret, so what follows is sealed under a new one.
940+
///
941+
/// Needed for [`Vault::grant_since`] to mean anything: the scope it can
942+
/// express is only as fine as the rotation boundaries that exist.
943+
pub fn rotate(&mut self) -> Result<Message, Error> {
944+
let state = self.state.take().ok_or(Error::NoSecret)?;
945+
let (state, msg) =
946+
Group::update(state, &self.rng).map_err(|e| Error::Group(e.to_string()))?;
947+
self.state = Some(state);
948+
self.save()?;
949+
msg.stamp(self.me)
894950
}
895951

896952
fn grant_inner(
897953
&mut self,
898954
bundle: LongTermKeyBundle,
899955
purpose: &str,
900956
unattended: bool,
957+
since: Option<u64>,
901958
) -> Result<(Message, GrantTag), Error> {
902959
let mut state = self.state.take().ok_or(Error::NoSecret)?;
903960
let tag = Self::tag_for(&state.dcgka.my_keys, &bundle, purpose)?;
@@ -914,8 +971,26 @@ impl Vault {
914971
}
915972
state.dcgka.pki = KeyRegistry::add_longterm_bundle(state.dcgka.pki, tag, bundle)
916973
.map_err(|e| Error::Crypto(e.to_string()))?;
917-
let (state, msg) =
974+
// **THE WHOLE OF A SCOPED GRANT IS THESE THREE LINES.** `Group::add`
975+
// hands the joiner whatever is in `y.secrets`, so the narrowing happens
976+
// here and is undone immediately afterwards. Keeping the full bundle in
977+
// a local rather than re-deriving it matters: `add` must not be able to
978+
// leave this vault holding less than it started with, or the subject
979+
// would lose the ability to read its own history by granting somebody.
980+
// `SecretBundleState` is only `Clone` under the crate's `test_utils`
981+
// feature, so the snapshot is of the secrets themselves — which are
982+
// `Clone` — and the bundle is rebuilt from them.
983+
let full: Vec<_> = state.secrets.secrets().cloned().collect();
984+
if let Some(since) = since {
985+
let kept: Vec<_> =
986+
full.iter().filter(|s| s.timestamp() >= since).cloned().collect();
987+
state.secrets = SecretBundle::from_secrets(kept);
988+
}
989+
let (mut state, msg) =
918990
Group::add(state, tag, &self.rng).map_err(|e| Error::Group(e.to_string()))?;
991+
if since.is_some() {
992+
state.secrets = SecretBundle::from_secrets(full);
993+
}
919994
self.state = Some(state);
920995
self.save()?;
921996
Ok((msg.stamp(self.me)?, tag))
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Can a grant be made to reach back only so far?
2+
//!
3+
//! **THIS IS THE SPIKE [D29](../../../docs/decisions.md) ASKED FOR, AND IT
4+
//! EXISTS BECAUSE THAT DECISION ENDS BY SAYING IT HAD NOT BEEN RUN.** The words
5+
//! were: *"This is read from the source. Nothing has been built, compiled or
6+
//! measured, and on the evidence of this repository's last three days that
7+
//! distinction is the whole difference between a finding and a result."*
8+
//!
9+
//! The finding was that a time-scoped grant is reachable on pinned
10+
//! p2panda-encryption 0.7.1. That much holds. The route does not: D29 proposed
11+
//! dropping to `Dcgka::add`, which takes the bundle as a parameter — but
12+
//! rebuilding what `EncryptionGroup::add` does around it needs `process_local`,
13+
//! and that is private. The way through is one layer higher and simpler.
14+
//! `GroupState::secrets` is a public field and `update_secrets` is a public
15+
//! setter, so the bundle a joiner receives is whatever is in that field when
16+
//! `add` runs.
17+
//!
18+
//! What this file asserts is the only thing that matters about that: a reader
19+
//! granted from a narrowed bundle can open what came after the cut and cannot
20+
//! open what came before it.
21+
22+
use diaswarm_core::{EPOCH_MS, Record};
23+
use diaswarm_keys::{Vault, wire};
24+
use p2panda_core::SigningKey;
25+
use p2panda_encryption::Rng;
26+
use p2panda_store::SqliteStoreBuilder;
27+
28+
const OFFSET: i64 = 12 * 3_600_000;
29+
30+
fn rand32() -> [u8; 32] {
31+
use std::time::{SystemTime, UNIX_EPOCH};
32+
let n = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
33+
let mut out = [0u8; 32];
34+
out[..16].copy_from_slice(&n.to_le_bytes());
35+
out[16..].copy_from_slice(&(n ^ 0x9E37_79B9_7F4A_7C15).to_le_bytes());
36+
out
37+
}
38+
39+
fn tmp(tag: &str) -> std::path::PathBuf {
40+
let p = std::env::temp_dir().join(format!(
41+
"diaswarm-scoped-{tag}-{}",
42+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
43+
));
44+
std::fs::create_dir_all(&p).unwrap();
45+
p
46+
}
47+
48+
fn day(epoch: i64) -> Vec<Record> {
49+
(0..4i64)
50+
.map(|i| {
51+
Record::new(epoch * EPOCH_MS + i * 300_000, "cgm")
52+
.set("mgdl", Some((100.0 + i as f64).into()))
53+
})
54+
.collect()
55+
}
56+
57+
fn now_secs() -> u64 {
58+
std::time::SystemTime::now()
59+
.duration_since(std::time::UNIX_EPOCH)
60+
.unwrap()
61+
.as_secs()
62+
}
63+
64+
/// A SCOPED GRANT OPENS WHAT CAME AFTER THE CUT, AND NOTHING BEFORE IT.
65+
///
66+
/// The subject seals a day, rotates the secret, seals another, then grants a
67+
/// reader with `since` set between the two rotations. The reader gets one day,
68+
/// not two — which is the sentence a clinician-facing "share the last 90 days"
69+
/// has to be able to mean.
70+
#[tokio::test(flavor = "multi_thread")]
71+
async fn a_grant_can_be_scoped_to_secrets_made_after_a_cutoff() {
72+
let rng = Rng::default();
73+
let subject_key = SigningKey::from_bytes(&rand32());
74+
let reader_key = SigningKey::from_bytes(&rand32());
75+
let root = tmp("scoped");
76+
77+
let mut subject = Vault::open(&root, OFFSET, &subject_key).expect("subject");
78+
let (subject_mgr, subject_bundle) = Vault::key_bundle(&rng).expect("bundle");
79+
let (reader_mgr, reader_bundle) = Vault::key_bundle(&rng).expect("bundle");
80+
subject.create(subject_mgr).expect("create");
81+
82+
// ---- the old day, under the first secret ----
83+
subject.seal(20_000, &day(20_000)).expect("seal old");
84+
85+
// **THE CUT HAS TO BE A WHOLE SECOND LATER.** `GroupSecret`'s timestamp is
86+
// UNIX *seconds* taken from `SystemTime::now()`, so two secrets minted in
87+
// the same second are indistinguishable to any filter over them. That is a
88+
// real limit on how finely this can scope, not a quirk of the test.
89+
std::thread::sleep(std::time::Duration::from_millis(1_100));
90+
let cutoff = now_secs();
91+
92+
subject.rotate().expect("rotate");
93+
assert!(subject.secrets() >= 2, "rotation did not add a secret");
94+
95+
// ---- the new day, under the second ----
96+
subject.seal(20_001, &day(20_001)).expect("seal new");
97+
98+
// ---- the grant, scoped ----
99+
let (welcome, _tag) =
100+
subject.grant_since(reader_bundle, "follow", cutoff).expect("scoped grant");
101+
102+
// Through the wire, because that is the only way to get an `Authentic` and
103+
// the only path a real reader has.
104+
let store = SqliteStoreBuilder::memory().build().await.expect("store");
105+
wire::publish_control(&store, &subject_key, &welcome).await.expect("publish");
106+
let mut arrived = wire::control_from(&store, &subject_key.verifying_key(), None)
107+
.await
108+
.expect("read control");
109+
let welcome = arrived.pop().expect("welcome");
110+
111+
// **THE SAME ROOT, SO THE READER CAN SEE THE SEGMENTS.** Counting secrets
112+
// is a proxy; what is actually claimed is what can be opened, and that
113+
// needs the ciphertext in front of it.
114+
let mut reader = Vault::open(&root, OFFSET, &reader_key).expect("reader");
115+
let registry =
116+
Vault::registry(&[(subject.subject(), subject_bundle.clone())]).expect("registry");
117+
reader.join(reader_mgr, registry, &subject_bundle, "follow", &welcome).expect("join");
118+
119+
// The reader holds exactly one secret: the one minted after the cut.
120+
assert_eq!(
121+
reader.secrets(),
122+
1,
123+
"a scoped grant handed over {} secrets, not 1",
124+
reader.secrets()
125+
);
126+
127+
// ---- and this is the claim itself ----
128+
let (got, skipped) = reader.read_reporting(i64::MIN).expect("reader reads");
129+
assert!(got.contains_key(&20_001), "the scoped reader could not open the day it was granted");
130+
assert!(
131+
!got.contains_key(&20_000),
132+
"the scoped reader opened the day BEFORE its cutoff — the narrowing did nothing"
133+
);
134+
assert_eq!(
135+
skipped.not_ours, 1,
136+
"expected exactly one day withheld, got {:?}",
137+
skipped
138+
);
139+
140+
// ---- and the subject did not lose anything by granting ----
141+
assert!(
142+
subject.secrets() >= 2,
143+
"granting narrowly left the subject holding {} secrets",
144+
subject.secrets()
145+
);
146+
let mine = subject.read_from(i64::MIN).expect("subject reads own history");
147+
assert_eq!(mine.len(), 2, "the subject can no longer read its own two days");
148+
}
149+
150+
/// AND AN UNSCOPED GRANT STILL REACHES BACK OVER EVERYTHING.
151+
///
152+
/// The control. If this passes and the one above does not, the narrowing did
153+
/// nothing; if both pass, `since` is what made the difference.
154+
#[tokio::test(flavor = "multi_thread")]
155+
async fn an_unscoped_grant_still_hands_over_the_whole_history() {
156+
let rng = Rng::default();
157+
let subject_key = SigningKey::from_bytes(&rand32());
158+
let reader_key = SigningKey::from_bytes(&rand32());
159+
let root = tmp("unscoped");
160+
161+
let mut subject = Vault::open(&root, OFFSET, &subject_key).expect("subject");
162+
let (subject_mgr, subject_bundle) = Vault::key_bundle(&rng).expect("bundle");
163+
let (reader_mgr, reader_bundle) = Vault::key_bundle(&rng).expect("bundle");
164+
subject.create(subject_mgr).expect("create");
165+
166+
subject.seal(20_000, &day(20_000)).expect("seal old");
167+
std::thread::sleep(std::time::Duration::from_millis(1_100));
168+
subject.rotate().expect("rotate");
169+
subject.seal(20_001, &day(20_001)).expect("seal new");
170+
171+
let (welcome, _tag) = subject.grant(reader_bundle, "follow").expect("grant");
172+
173+
let store = SqliteStoreBuilder::memory().build().await.expect("store");
174+
wire::publish_control(&store, &subject_key, &welcome).await.expect("publish");
175+
let mut arrived = wire::control_from(&store, &subject_key.verifying_key(), None)
176+
.await
177+
.expect("read control");
178+
let welcome = arrived.pop().expect("welcome");
179+
180+
let mut reader = Vault::open(&root, OFFSET, &reader_key).expect("reader");
181+
let registry =
182+
Vault::registry(&[(subject.subject(), subject_bundle.clone())]).expect("registry");
183+
reader.join(reader_mgr, registry, &subject_bundle, "follow", &welcome).expect("join");
184+
185+
assert!(
186+
reader.secrets() >= 2,
187+
"an unscoped grant handed over only {} secret(s) — the control is broken",
188+
reader.secrets()
189+
);
190+
let (got, skipped) = reader.read_reporting(i64::MIN).expect("reader reads");
191+
assert_eq!(got.len(), 2, "an unscoped reader should see both days, saw {}", got.len());
192+
assert_eq!(skipped.not_ours, 0, "an unscoped reader was refused a day: {:?}", skipped);
193+
}

‎docs/decisions.md‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,11 +1219,38 @@ rotation boundaries rather than to days. Day-granular windows therefore mean
12191219
rotating daily — and D26 measured that: a year of daily rotation is a
12201220
366-secret bundle and a **0.76 ms** welcome.
12211221

1222-
⚠️ **This is read from the source. Nothing has been built, compiled or
1223-
measured**, and on the evidence of this repository's last three days that
1224-
distinction is the whole difference between a finding and a result. The spike
1225-
that would settle it is small: filter a bundle, `Dcgka::add` with it, assert the
1226-
joiner opens epoch N and fails on epoch N−1.
1222+
#### ✅ The spike ran, 2026-09-16 — and the route above is wrong
1223+
1224+
The conclusion holds: **a time-scoped grant is reachable on pinned 0.7.1, with no
1225+
fork.** `Vault::grant_since` ships it, and
1226+
`crates/diaswarm-keys/tests/scoped_grant.rs` asserts the thing that matters —
1227+
a reader granted with a cutoff opens the day after it, is refused the day
1228+
before it (`Skipped::not_ours == 1`), and the subject still reads its own
1229+
history. An unscoped grant is the control and still hands over everything.
1230+
1231+
**But not by dropping to `Dcgka::add`.** That takes the bundle as a parameter,
1232+
as read — however rebuilding what `EncryptionGroup::add` does around it needs
1233+
`process_local`, which is **private**. The table above lists the four things
1234+
that are public and misses the one that is not.
1235+
1236+
**The way through is one layer higher and simpler.** `GroupState::secrets` is a
1237+
public field and `EncryptionGroup::update_secrets` is a public setter, so the
1238+
bundle a joiner receives is whatever sits in that field when `add` runs. Narrow
1239+
it, add, put it back — three lines, no private API, no fork.
1240+
1241+
⚠️ **Two limits the source reading did not surface**, both now in
1242+
`grant_since`'s doc:
1243+
1244+
* `GroupSecret`'s timestamp is **UNIX seconds stamped at creation**, not a
1245+
property of the data. So this scopes by *when the secret was minted*, and
1246+
"the last 90 days" only means that if the subject rotates on a schedule.
1247+
Without rotation it is a no-op, because one secret covers everything.
1248+
`Vault::rotate` exists for that and is what the spike uses.
1249+
* Two secrets minted in the same second are indistinguishable to any filter over
1250+
them. Second granularity is the floor.
1251+
1252+
**It is not a revocation.** A reader already holding an older secret keeps it.
1253+
This narrows what a *new* grant hands over. [`Vault::revoke`] is what bites.
12271254

12281255
#### Sequencing, and why this order
12291256

0 commit comments

Comments
 (0)