This repository has no description
11 kB
337 lines
1use std::collections::BTreeMap;
2
3use base64::Engine;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use knot_runtime::{Entropy, PublicKeyBytes, Signer};
6use knot_types::{ActorId, KnotId, KnotServiceUrl, OwnerDid, RepoDid, RepoRkey};
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10const PLC_OP_TYPE: &str = "plc_operation";
11const ATPROTO_METHOD: &str = "atproto";
12const KNOT_HOME_SERVICE: &str = "tangled_knot";
13const KNOT_HOME_TYPE: &str = "TangledKnot";
14const DID_PLC_PREFIX: &str = "did:plc:";
15const DID_SUFFIX_LEN: usize = 24;
16const MINT_NONCE_LEN: usize = 16;
17
18#[derive(Debug, thiserror::Error)]
19pub enum IdentityError {
20 #[error("plc operation couldn't be encoded: {0}")]
21 Encode(String),
22 #[error("derived did:plc isn't valid DID: {0}")]
23 Did(#[from] knot_types::ParseError),
24}
25
26#[derive(Serialize)]
27struct PlcService {
28 r#type: &'static str,
29 endpoint: String,
30}
31
32#[derive(Serialize)]
33struct PlcOperation {
34 #[serde(rename = "type")]
35 op_type: &'static str,
36 #[serde(rename = "rotationKeys")]
37 rotation_keys: Vec<String>,
38 #[serde(rename = "verificationMethods")]
39 verification_methods: BTreeMap<&'static str, String>,
40 #[serde(rename = "alsoKnownAs")]
41 also_known_as: Vec<String>,
42 services: BTreeMap<&'static str, PlcService>,
43 prev: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 sig: Option<String>,
46}
47
48pub struct PreparedRepoDid {
49 pub did: RepoDid,
50 operation_json: Vec<u8>,
51}
52
53impl PreparedRepoDid {
54 pub fn operation_json(&self) -> &[u8] {
55 &self.operation_json
56 }
57}
58
59fn did_key(public: &PublicKeyBytes) -> String {
60 format!("did:key:{}", multikey_secp256k1(public))
61}
62
63fn multikey_secp256k1(public: &PublicKeyBytes) -> ActorId {
64 ActorId::from_secp256k1(public.as_bytes())
65}
66
67fn encode_cbor(operation: &PlcOperation) -> Result<Vec<u8>, IdentityError> {
68 serde_ipld_dagcbor::to_vec(operation).map_err(|error| IdentityError::Encode(error.to_string()))
69}
70
71pub struct MintNonce([u8; MINT_NONCE_LEN]);
72
73impl MintNonce {
74 pub fn mint(entropy: &dyn Entropy, owner: &OwnerDid, rkey: &RepoRkey) -> Self {
75 let mut bytes = [0u8; MINT_NONCE_LEN];
76 entropy.derive(mint_label(owner, rkey)).fill(&mut bytes);
77 Self(bytes)
78 }
79
80 fn as_bytes(&self) -> &[u8] {
81 &self.0
82 }
83}
84
85fn mint_label(owner: &OwnerDid, rkey: &RepoRkey) -> u64 {
86 [owner.as_str(), rkey.as_str()]
87 .iter()
88 .flat_map(|part| part.bytes().chain(std::iter::once(0u8)))
89 .fold(0xcbf2_9ce4_8422_2325u64, |hash, byte| {
90 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
91 })
92}
93
94pub fn prepare_repo_did(
95 signer: &dyn Signer,
96 knot_service_url: &KnotServiceUrl,
97 mint_nonce: &MintNonce,
98) -> Result<PreparedRepoDid, IdentityError> {
99 let tag = base32::encode(
100 base32::Alphabet::Rfc4648 { padding: false },
101 mint_nonce.as_bytes(),
102 )
103 .to_lowercase();
104 let base = knot_service_url.as_str();
105 let mut operation = PlcOperation {
106 op_type: PLC_OP_TYPE,
107 rotation_keys: vec![did_key(&signer.public_key())],
108 verification_methods: BTreeMap::new(),
109 also_known_as: Vec::new(),
110 services: BTreeMap::from([(
111 KNOT_HOME_SERVICE,
112 PlcService {
113 r#type: KNOT_HOME_TYPE,
114 endpoint: format!("{base}/repo/{tag}"),
115 },
116 )]),
117 prev: None,
118 sig: None,
119 };
120
121 let unsigned = encode_cbor(&operation)?;
122 operation.sig = Some(URL_SAFE_NO_PAD.encode(signer.sign(&unsigned).as_bytes()));
123
124 let signed = encode_cbor(&operation)?;
125 let did = derive_did_plc(&signed)?;
126 let operation_json =
127 serde_json::to_vec(&operation).map_err(|error| IdentityError::Encode(error.to_string()))?;
128 Ok(PreparedRepoDid {
129 did,
130 operation_json,
131 })
132}
133
134fn derive_did_plc(signed_cbor: &[u8]) -> Result<RepoDid, IdentityError> {
135 let digest = Sha256::digest(signed_cbor);
136 let encoded =
137 base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &digest).to_lowercase();
138 let suffix: String = encoded.chars().take(DID_SUFFIX_LEN).collect();
139 Ok(RepoDid::new(format!("{DID_PLC_PREFIX}{suffix}"))?)
140}
141
142pub fn knot_did_document(
143 knot: &KnotId,
144 signing_key: &PublicKeyBytes,
145 service_url: &KnotServiceUrl,
146) -> serde_json::Value {
147 did_web_document(knot, signing_key, service_url)
148}
149
150fn did_web_document(
151 id: &KnotId,
152 signing_key: &PublicKeyBytes,
153 service_url: &KnotServiceUrl,
154) -> serde_json::Value {
155 serde_json::json!({
156 "@context": [
157 "https://www.w3.org/ns/did/v1",
158 "https://w3id.org/security/multikey/v1",
159 "https://w3id.org/security/suites/secp256k1-2019/v1"
160 ],
161 "id": id,
162 "verificationMethod": [{
163 "id": format!("{id}#{ATPROTO_METHOD}"),
164 "type": "Multikey",
165 "controller": id,
166 "publicKeyMultibase": multikey_secp256k1(signing_key)
167 }],
168 "service": [{
169 "id": format!("#{KNOT_HOME_SERVICE}"),
170 "type": KNOT_HOME_TYPE,
171 "serviceEndpoint": service_url
172 }]
173 })
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::test_support::*;
180 use knot_runtime::verify;
181
182 #[test]
183 fn did_derivation_is_deterministic_and_nonce_sensitive() {
184 let key = runtime_signer(2);
185 let first = prepare_repo_did(
186 &key,
187 &KnotServiceUrl::new("https://nel.pet").unwrap(),
188 &repo_nonce(101),
189 )
190 .unwrap();
191 let again = prepare_repo_did(
192 &key,
193 &KnotServiceUrl::new("https://nel.pet").unwrap(),
194 &repo_nonce(101),
195 )
196 .unwrap();
197 assert_eq!(first.did, again.did);
198 assert_eq!(first.operation_json(), again.operation_json());
199
200 let slashed = prepare_repo_did(
201 &key,
202 &KnotServiceUrl::new("https://nel.pet/").unwrap(),
203 &repo_nonce(101),
204 )
205 .unwrap();
206 assert_eq!(
207 first.did, slashed.did,
208 "a trailing slash on the knot url changes neither the endpoint nor the did"
209 );
210
211 let other_nonce = prepare_repo_did(
212 &key,
213 &KnotServiceUrl::new("https://nel.pet").unwrap(),
214 &repo_nonce(102),
215 )
216 .unwrap();
217 assert_ne!(
218 first.did, other_nonce.did,
219 "shared knot key no longer distinguishes repos; mint nonce must"
220 );
221 }
222
223 #[test]
224 fn the_derived_did_plc_is_pinned_and_well_formed() {
225 let prepared = prepare_repo_did(
226 &runtime_signer(1),
227 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(),
228 &repo_nonce(104),
229 )
230 .unwrap();
231 let did = prepared.did.as_str();
232 assert_eq!(
233 did, "did:plc:obafda42ebtgg5thl7bzyjso",
234 "any change to this value means did:plc derivation no longer matches the PLC directory"
235 );
236 let suffix = did.strip_prefix(DID_PLC_PREFIX).unwrap();
237 assert_eq!(suffix.len(), DID_SUFFIX_LEN);
238 assert!(
239 suffix
240 .chars()
241 .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)),
242 "the did:plc suffix is lowercase base32"
243 );
244 }
245
246 #[test]
247 fn the_operation_signature_verifies_against_the_repo_key_over_the_unsigned_cbor() {
248 let key = runtime_signer(5);
249 let prepared = prepare_repo_did(
250 &key,
251 &KnotServiceUrl::new("https://nel.pet").unwrap(),
252 &repo_nonce(106),
253 )
254 .unwrap();
255 let operation: serde_json::Value =
256 serde_json::from_slice(prepared.operation_json()).unwrap();
257
258 let signature_b64 = operation["sig"].as_str().unwrap();
259 let signature =
260 knot_runtime::Signature::from_bytes(URL_SAFE_NO_PAD.decode(signature_b64).unwrap());
261
262 let mut unsigned = operation.clone();
263 unsigned.as_object_mut().unwrap().remove("sig");
264 let unsigned_cbor = serde_ipld_dagcbor::to_vec(&unsigned).unwrap();
265
266 assert!(
267 verify(&key.public_key(), &unsigned_cbor, &signature),
268 "genesis op is self-signed by repo rotation key over its unsigned dag-cbor"
269 );
270 }
271
272 #[test]
273 fn the_genesis_op_marks_the_home_knot_and_has_no_signing_key() {
274 let key = runtime_signer(6);
275 let prepared = prepare_repo_did(
276 &key,
277 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(),
278 &repo_nonce(107),
279 )
280 .unwrap();
281 let operation: serde_json::Value =
282 serde_json::from_slice(prepared.operation_json()).unwrap();
283
284 assert_eq!(operation["type"], "plc_operation");
285 assert_eq!(operation["prev"], serde_json::Value::Null);
286 assert_eq!(operation["alsoKnownAs"], serde_json::json!([]));
287 assert!(
288 operation["services"][KNOT_HOME_SERVICE]["endpoint"]
289 .as_str()
290 .unwrap()
291 .starts_with("https://knot.oyster.cafe/repo/")
292 );
293 assert_eq!(
294 operation["services"][KNOT_HOME_SERVICE]["type"],
295 KNOT_HOME_TYPE
296 );
297 assert!(operation["services"]["atproto_pds"].is_null());
298 assert_eq!(
299 operation["verificationMethods"],
300 serde_json::json!({}),
301 "repos have no atproto signing key"
302 );
303 let expected_key = did_key(&key.public_key());
304 assert_eq!(operation["rotationKeys"][0], expected_key);
305 assert_eq!(operation["rotationKeys"].as_array().unwrap().len(), 1);
306 }
307
308 #[test]
309 fn the_knot_document_declares_its_signing_key_and_tangled_knot_service() {
310 let key = runtime_signer(7);
311 let knot = KnotId::new("did:web:knot.oyster.cafe").unwrap();
312 let document = knot_did_document(
313 &knot,
314 &key.public_key(),
315 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(),
316 );
317
318 assert_eq!(
319 document["verificationMethod"][0]["id"], "did:web:knot.oyster.cafe#atproto",
320 "knot publishes its signing key under the atproto method"
321 );
322 assert_eq!(
323 document["verificationMethod"][0]["publicKeyMultibase"],
324 serde_json::json!(multikey_secp256k1(&key.public_key())),
325 "published verification method is the knot's own signing key"
326 );
327 assert_eq!(
328 document["service"][0],
329 serde_json::json!({
330 "id": "#tangled_knot",
331 "type": "TangledKnot",
332 "serviceEndpoint": "https://knot.oyster.cafe"
333 }),
334 "knot self-declares its tangled_knot service at the knot root"
335 );
336 }
337}