1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
|
//! Values sealed for their readers: encrypted, with age, to the SSH keys of the users
//! who may read them (`registry::User::keys`), so only they open them: every node syncs,
//! stores and remakes a sealed value as the string it is, and none decrypts it. A sealed
//! value is `cs1.<the age file, base64url, unpadded>`, as `age -R <their keys>` writes
//! it (binary, not armored).
//!
//! Any value anywhere may be sealed, for anyone. Where the swarm declares it
//! (`_continuum/access/sealed`, `declared`), every value must be: a list of
//! `{"path": [...], "readers": ["alice", …]}`, `*` in a path for any one key. Without
//! `readers`, a sealed value for anyone; with them, one sealed for exactly their keys, as
//! the registry has them when it is written. A node checks that without decrypting: an
//! age file names each `ssh-ed25519` recipient by a tag, the first four bytes of the
//! SHA-256 of its key (`tag`). So a forgotten reader or a stray recipient is refused
//! (`check`), at the API and, with the swarm's policy, by every node on receipt
//! (`access::rules`). A writer who means to leak the value has it anyway: it guards
//! against mistakes, not against the writer.
use std::collections::BTreeSet;
use base64::{
Engine,
engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD as B64URL},
};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use super::{envelope, registry, sshsig};
use crate::document::{ReadDoc, meta, store};
/// What every sealed value starts with.
const PREFIX: &str = "cs1.";
/// How an age file starts.
const AGE_HEADER: &str = "age-encryption.org/v1";
/// The recipient stanzas of SSH Ed25519 keys.
const SSH_ED25519: &str = "ssh-ed25519";
/// Where the swarm declares the sealed paths.
pub(crate) fn declared_at() -> Vec<String> {
meta::path(&[meta::ACCESS, "sealed"])
}
/// A path whose values are sealed, and for whom, if it says.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Declared {
pub(crate) path: Vec<String>,
pub(crate) readers: Option<Vec<String>>,
}
/// What `doc` declares sealed; an entry that doesn't read as one is left out.
pub(crate) fn declared(doc: &impl ReadDoc) -> Vec<Declared> {
let Ok(Some(JsonValue::Array(entries))) = store::read_plain(doc, &declared_at()) else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let strings = |value: &JsonValue| -> Option<Vec<String>> {
value
.as_array()?
.iter()
.map(|segment| segment.as_str().map(str::to_owned))
.collect()
};
let path = strings(entry.get("path")?)?;
let readers = match entry.get("readers") {
None => None,
Some(readers) => Some(strings(readers)?),
};
Some(Declared { path, readers })
})
.collect()
}
/// The declaration `path` is the place of, if any: of the same length, `*` for any key.
pub(crate) fn place<'a>(declared: &'a [Declared], path: &[String]) -> Option<&'a Declared> {
declared
.iter()
.find(|declared| declared.path.len() == path.len() && agree(&declared.path, path))
}
/// Whether `path` is within a declared place (below it).
pub(crate) fn within(declared: &[Declared], path: &[String]) -> bool {
declared
.iter()
.any(|declared| path.len() > declared.path.len() && agree(&declared.path, path))
}
/// Whether a declared place may be at or below `path`.
fn reaches(declared: &[Declared], path: &[String]) -> bool {
declared.iter().any(|declared| agree(&declared.path, path))
}
fn agree(pattern: &[String], path: &[String]) -> bool {
pattern
.iter()
.zip(path)
.all(|(segment, key)| segment == "*" || segment == key)
}
/// How an age file names `key` as a recipient: the first four bytes of the SHA-256 of
/// its SSH encoding, base64, unpadded.
pub(crate) fn tag(key: &sshsig::PublicKey) -> String {
STANDARD_NO_PAD.encode(&Sha256::digest(key.blob())[..4])
}
/// The tags of the `ssh-ed25519` recipients `sealed` is for, if it is a sealed value;
/// why not, if not, or if it is for recipients of another kind (but grease stanzas,
/// which age adds to keep parsers honest).
pub(crate) fn recipients(sealed: &str) -> Result<BTreeSet<String>, String> {
let file = sealed
.strip_prefix(PREFIX)
.ok_or("not a sealed value (cs1.<age file>)")?;
let bytes = B64URL
.decode(file)
.map_err(|_| "not a sealed value: it doesn't decode".to_owned())?;
let mut lines = bytes.split(|byte| *byte == b'\n');
if lines.next() != Some(AGE_HEADER.as_bytes()) {
return Err("not a sealed value: not an age file (binary, not armored)".to_owned());
}
let mut tags = BTreeSet::new();
for line in lines {
if line.starts_with(b"--- ") {
return Ok(tags);
}
let Some(stanza) = line.strip_prefix(b"-> ") else {
continue;
};
let stanza = std::str::from_utf8(stanza)
.map_err(|_| "not a sealed value: its header isn't text".to_owned())?;
let mut args = stanza.split(' ');
match args.next() {
Some(SSH_ED25519) => {
let tag = args
.next()
.ok_or("not a sealed value: a stanza without a tag")?;
tags.insert(tag.to_owned());
}
Some(kind) if kind.ends_with("-grease") => {}
Some(kind) => return Err(format!("sealed for a recipient of another kind ({kind})")),
None => return Err("not a sealed value: an empty stanza".to_owned()),
}
}
Err("not a sealed value: its header doesn't end".to_owned())
}
/// Checks `value`, written at the place of `declared`, against the registry `doc` holds
/// (as its writer saw it): a sealed value, or, signed by its user (`access::envelope`),
/// one holding a sealed value or `null` (deleted); with readers declared, sealed for
/// exactly their keys.
pub(crate) fn check(
doc: &impl ReadDoc,
declared: &Declared,
value: &JsonValue,
) -> Result<(), String> {
let JsonValue::String(text) = value else {
return Err("a sealed value is a string (cs1.<age file>)".to_owned());
};
let sealed = if text.starts_with("cv1.") {
match envelope::read(text)?.value {
JsonValue::Null => return Ok(()),
JsonValue::String(sealed) => sealed,
_ => return Err("signed, but not sealed".to_owned()),
}
} else {
text.clone()
};
let found = recipients(&sealed)?;
let Some(readers) = &declared.readers else {
return Ok(());
};
let wanted: BTreeSet<String> = readers
.iter()
.filter_map(|reader| registry::user(doc, reader))
.flat_map(|user| user.keys.into_values())
.map(|key| tag(&key))
.collect();
if let Some(missing) = wanted.difference(&found).next() {
let owner = readers
.iter()
.find(|reader| {
registry::user(doc, reader)
.is_some_and(|user| user.keys.values().any(|key| tag(key) == *missing))
})
.cloned()
.unwrap_or_default();
return Err(format!("not sealed for {owner}'s key {missing}"));
}
if let Some(stray) = found.difference(&wanted).next() {
return Err(format!("sealed for a key of none of its readers ({stray})"));
}
Ok(())
}
/// Checks what is at `path` in `doc` after a write there: nothing within a declared
/// place but its value, and every value at one (at `path` or below it) as `check` says.
/// Why not, if not.
pub(crate) fn kept(doc: &impl ReadDoc, path: &[String]) -> Result<(), String> {
let declared = declared(doc);
if declared.is_empty() || !reaches(&declared, path) {
return Ok(());
}
if within(&declared, path) {
return Err(format!("/{} is within a sealed value", path.join("/")));
}
fn walk(
doc: &impl ReadDoc,
declared: &[Declared],
value: &JsonValue,
at: &mut Vec<String>,
) -> Result<(), String> {
if let Some(place) = place(declared, at) {
return check(doc, place, value).map_err(|why| format!("/{}: {why}", at.join("/")));
}
if let JsonValue::Object(entries) = value {
for (key, value) in entries {
at.push(key.clone());
if reaches(declared, at) {
walk(doc, declared, value, at)?;
}
at.pop();
}
}
Ok(())
}
match store::read_path(doc, path) {
Ok(Some(value)) => walk(doc, &declared, &value, &mut path.to_vec()),
Ok(None) => Ok(()),
Err(error) => Err(error.to_string()),
}
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
/// A sealed value whose header is as age writes it for `keys`: what nodes read of
/// it (the body, which they don't, is made up).
pub(crate) fn sealed_for(keys: &[sshsig::PublicKey]) -> String {
let mut file = format!("{AGE_HEADER}\n");
for key in keys {
file.push_str(&format!(
"-> {SSH_ED25519} {} AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n",
tag(key)
));
}
file.push_str("-> x-grease 1 2\n\n--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n");
let mut bytes = file.into_bytes();
bytes.extend_from_slice(&[0, 1, 2, 3]);
format!("{PREFIX}{}", B64URL.encode(bytes))
}
}
#[cfg(test)]
mod tests {
use super::testing::sealed_for;
use super::*;
use crate::document::Document;
use crate::document::testing::change;
use serde_json::json;
fn path(text: &str) -> Vec<String> {
text.split('/').map(str::to_owned).collect()
}
fn key(seed: u8) -> sshsig::PublicKey {
sshsig::PublicKey(
ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
.verifying_key()
.to_bytes(),
)
}
/// Alice with two keys, bob with one; `secrets/*` sealed for both, `notes` for
/// anyone.
fn swarm() -> Document {
let mut doc = Document::new();
change(&mut doc, |doc| {
let mut alice = registry::entry("root", 1, &[]);
alice["keys"] = json!({ "laptop": key(1).line(), "phone": key(2).line() });
store::write_path(doc, ®istry::user_path("alice"), &alice).unwrap();
let mut bob = registry::entry("root", 1, &[]);
bob["keys"] = json!({ "laptop": key(3).line() });
store::write_path(doc, ®istry::user_path("bob"), &bob).unwrap();
store::write_path(
doc,
&declared_at(),
&json!([
{"path": ["secrets", "*"], "readers": ["alice", "bob"]},
{"path": ["notes"]},
{"path": "junk"},
]),
)
.unwrap();
});
doc
}
/// The tag of a key, as age computes it, of OpenSSH's test key.
#[test]
fn tags_as_age_has_them() {
let key = sshsig::PublicKey::parse(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIlizSIN3DQV78VPNjVvvwjgf+P5HqbPYCYu3BOMdjAE",
)
.unwrap();
let digest = Sha256::digest(key.blob());
assert_eq!(tag(&key), STANDARD_NO_PAD.encode(&digest[..4]));
assert_eq!(tag(&key).len(), 6);
}
#[test]
fn declarations() {
let doc = swarm();
let declared = declared(&doc);
assert_eq!(declared.len(), 2);
assert!(place(&declared, &path("secrets/db")).is_some());
assert!(place(&declared, &path("secrets")).is_none());
assert!(within(&declared, &path("secrets/db/x")));
assert!(place(&declared, &path("notes")).unwrap().readers.is_none());
}
/// Sealed for exactly its readers' keys, or for anyone where none are declared;
/// signed and sealed alike; anything else refused.
#[test]
fn what_a_sealed_value_must_be() {
let doc = swarm();
let declared = declared(&doc);
let secrets = place(&declared, &path("secrets/db")).unwrap();
let notes = place(&declared, &path("notes")).unwrap();
let all = sealed_for(&[key(1), key(2), key(3)]);
assert!(check(&doc, secrets, &json!(all)).is_ok());
// A key forgotten, a stray one.
assert!(check(&doc, secrets, &json!(sealed_for(&[key(1), key(3)]))).is_err());
assert!(
check(
&doc,
secrets,
&json!(sealed_for(&[key(1), key(2), key(3), key(4)]))
)
.is_err()
);
// Plain, not a string, not age.
assert!(check(&doc, secrets, &json!("hunter2")).is_err());
assert!(check(&doc, secrets, &json!({"password": "hunter2"})).is_err());
assert!(check(&doc, secrets, &json!("cs1.bm90IGFnZQ")).is_err());
// For anyone.
assert!(check(&doc, notes, &json!(sealed_for(&[key(9)]))).is_ok());
assert!(check(&doc, notes, &json!("plain")).is_err());
// Signed: holding a sealed value, or null.
let signed = |value: JsonValue| {
let payload =
json!({"path": ["notes"], "value": value, "user": "alice", "at": 1, "prev": ""});
format!("cv1.{}.c2ln", B64URL.encode(payload.to_string()))
};
assert!(check(&doc, notes, &json!(signed(json!(sealed_for(&[key(9)]))))).is_ok());
assert!(check(&doc, notes, &json!(signed(JsonValue::Null))).is_ok());
assert!(check(&doc, notes, &json!(signed(json!("plain")))).is_err());
}
/// After a write: every declared place at or below it as `check` says, nothing
/// within one.
#[test]
fn what_a_write_keeps() {
let mut doc = swarm();
let all = sealed_for(&[key(1), key(2), key(3)]);
change(&mut doc, |doc| {
store::write_path(doc, &path("secrets"), &json!({"db": all, "other": all})).unwrap();
store::write_path(doc, &path("elsewhere"), &json!("plain")).unwrap();
});
assert!(kept(&doc, &path("secrets")).is_ok());
assert!(kept(&doc, &path("elsewhere")).is_ok());
change(&mut doc, |doc| {
store::write_path(doc, &path("secrets/web"), &json!("plain")).unwrap();
});
assert!(kept(&doc, &path("secrets")).is_err());
assert!(kept(&doc, &path("secrets/web")).is_err());
assert!(kept(&doc, &path("secrets/db/x")).is_err());
}
}
|