ssb_keyfile/
classic.rs

1use serde::{Deserialize, Serialize};
2use serde_json::error::Error as JsonError;
3use ssb_crypto::AsBytes;
4pub use ssb_crypto::Keypair;
5use std::fs::{File, OpenOptions};
6use std::io::{self, Read, Write};
7use std::path::Path;
8use thiserror::Error;
9
10/// Read and parse the key file from the given [Read] stream.
11/// May read more bytes than absolutely necessary.
12pub fn read<R: Read>(mut r: R) -> Result<Keypair, KeyFileError> {
13    // A standard js ssb secret file is 727 bytes
14    let mut buf = [0u8; 1024];
15    r.read(&mut buf)?;
16    let sec_str = std::str::from_utf8(&buf)?;
17    read_from_str(sec_str)
18}
19
20/// Read and parse the key file at the given path.
21pub fn read_from_path<P: AsRef<Path>>(path: P) -> Result<Keypair, KeyFileError> {
22    let f = File::open(&path)?;
23    read(f)
24}
25
26/// Load keys from the string contents of a js ssb secret file.
27pub fn read_from_str(s: &str) -> Result<Keypair, KeyFileError> {
28    let raw_sec_str = s
29        .lines()
30        .filter(|s| !s.starts_with('#'))
31        .collect::<Vec<&str>>()
32        .concat();
33    let sec_str = raw_sec_str.trim_matches(char::from(0));
34    let sec = serde_json::from_str::<SecretFile>(&sec_str)?;
35    if let Some(kp) = Keypair::from_base64(&sec.private) {
36        Ok(kp)
37    } else {
38        Err(KeyFileError::Decode)
39    }
40}
41
42/// Create a string containing the content of a new secret file for the given keys,
43/// in js ssb commented-json format.
44pub fn write(keypair: &Keypair, mut w: impl Write) -> Result<(), io::Error> {
45    let mut id = encode_key(&keypair.public.0);
46    id.insert(0, '@');
47
48    let kf = SecretFileFull {
49        curve: "ed25519",
50        public: encode_key(&keypair.public.0),
51        private: encode_key(keypair.as_bytes()),
52        id,
53    };
54
55    w.write(PRE_COMMENT.as_bytes())?;
56    let formatter = serde_json::ser::PrettyFormatter::with_indent(b"  ");
57    let mut ser = serde_json::Serializer::with_formatter(&mut w, formatter);
58    kf.serialize(&mut ser).unwrap();
59
60    w.write(POST_COMMENT.as_bytes())?;
61    w.write(kf.id.as_bytes())?;
62    Ok(())
63}
64
65/// Write the given [Keypair] to a new file at the specified path. `path` should include the file name.
66/// Fails if the path exists, or if the path is a directory.
67pub fn write_to_path<P: AsRef<Path>>(keypair: &Keypair, path: P) -> Result<(), io::Error> {
68    // NOTE: Path::is_dir() returns false for eg "/tmp/foo/" if foo doesn't exist;
69    //  ie it doesn't check if the path 'looks like' a dir. We'd have to do that manually.
70
71    if let Some(dir) = path.as_ref().parent() {
72        if !dir.exists() {
73            std::fs::create_dir_all(&dir)?
74        }
75    }
76
77    let f = OpenOptions::new()
78        .create_new(true)
79        .write(true)
80        .open(&path)?;
81    write(keypair, f)
82}
83
84/// Create a string with the js ssb commented-json format string encoding of the given [Keypair].
85pub fn write_to_string(keypair: &Keypair) -> String {
86    let mut out = Vec::with_capacity(727);
87    write(keypair, io::Cursor::new(&mut out)).unwrap();
88    String::from_utf8(out).unwrap()
89}
90
91/// Generate a new [Keypair] and write it to the given [Write] stream.
92pub fn generate(w: impl Write) -> Result<Keypair, io::Error> {
93    let keypair = Keypair::generate();
94    write(&keypair, w)?;
95    Ok(keypair)
96}
97
98/// Generate a new [Keypair] and write it to a new file at the specified [Path].
99/// The path should include the file name.
100/// Fails if the path exists, or if the path is a directory.
101pub fn generate_at_path<P: AsRef<Path>>(path: P) -> Result<Keypair, io::Error> {
102    let keypair = Keypair::generate();
103    write_to_path(&keypair, path)?;
104    Ok(keypair)
105}
106
107// The libsodium "secret" key also contains the public key,
108// so there's no need to read the other fields.
109#[derive(Debug, Deserialize)]
110struct SecretFile {
111    private: String,
112}
113
114#[derive(Debug, Serialize)]
115struct SecretFileFull {
116    curve: &'static str,
117    public: String,
118    private: String,
119    id: String,
120}
121
122fn encode_key(bytes: &[u8]) -> String {
123    let mut out = base64::encode_config(bytes, base64::STANDARD);
124    out.push_str(".ed25519");
125    out
126}
127
128/// The reasons why reading keys from a file can fail.
129#[derive(Error, Debug)]
130pub enum KeyFileError {
131    #[error("Failed to read from file")]
132    FileRead(#[from] io::Error),
133
134    #[error("Secret file isn't valid utf8")]
135    Utf8(#[from] std::str::Utf8Error),
136
137    #[error("Failed to parse secret file as json")]
138    Json(#[from] JsonError),
139
140    #[error("Failed to decode key pair")]
141    Decode,
142}
143
144const PRE_COMMENT: &str = "# WARNING: Never show this to anyone.
145# WARNING: Never edit it or use it on multiple devices at once.
146#
147# This is your SECRET, it gives you magical powers. With your secret you can
148# sign your messages so that your friends can verify that the messages came
149# from you. If anyone learns your secret, they can use it to impersonate you.
150#
151# If you use this secret on more than one device you will create a fork and
152# your friends will stop replicating your content.
153#
154";
155
156const POST_COMMENT: &str = "
157#
158# The only part of this file that's safe to share is your public name:
159#
160#   ";
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::path::PathBuf;
166
167    fn test_data_file(name: &str) -> PathBuf {
168        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
169        d.push("test-data");
170        d.push(name);
171        d
172    }
173
174    #[test]
175    fn read_js_file() {
176        let keypair = read_from_path(test_data_file("secret")).unwrap();
177
178        assert_eq!(
179            keypair.public.as_base64(),
180            "H2qXeS5sOKUqaGNFgRJ6qR48+lAeP0C9lq9IVlQMotc="
181        );
182    }
183
184    #[test]
185    fn read_go_file() {
186        let keypair = read_from_path(test_data_file("secret-go")).unwrap();
187        assert_eq!(
188            keypair.public.as_base64(),
189            "H2qXeS5sOKUqaGNFgRJ6qR48+lAeP0C9lq9IVlQMotc="
190        );
191    }
192
193    #[test]
194    fn generate_file() {
195        let dir = tempfile::TempDir::new().unwrap();
196
197        // path must not be a dir
198        assert!(generate_at_path(dir.path()).is_err());
199        let path = dir.path().join("secret");
200
201        let kp = generate_at_path(&path).unwrap();
202
203        // file must not exist
204        assert!(generate_at_path(&path).is_err());
205
206        let kp2 = read_from_path(&path).unwrap();
207        assert_eq!(kp.public, kp2.public);
208
209        // should create intermediate dirs if they don't exist
210        let path = dir.path().join("foo").join("bar");
211        let kp = generate_at_path(&path).unwrap();
212        let kp2 = read_from_path(&path).unwrap();
213        assert_eq!(kp.public, kp2.public);
214    }
215}