Remove `oa identity`

02cdaea275e0 · AtlantisPleb · · parent 52a821f72b83

Remove `oa identity`

It generated a BIP-39 seed and derived a Nostr npub and a Bitcoin mainnet
wallet from it. Nothing consumed any of that: the only readers were
`identity.rs` itself and its own subcommand arm. No command signed anything, and
nothing used the wallet.

It came across in the port for parity, and then grew: sealing the seed under an
OS keychain wrapping key. That is what made it worth removing rather than
leaving. The keychain requirement fails wherever `HOME` is not a real macOS
home — CI, containers, an agent fleet on one machine, which is what this CLI
exists for — and on a fresh machine `identity create` reported

    oa: The new seed could not be stored … the OS keychain refused to store
        the identity wrapping key

while exiting 0, so a script could not tell. It also raised a system keychain
dialog offering to reset the user's keychain to defaults. A feature with no
caller does not get to do that.

It is also Nostr, which this pass had already decided against when #80 was
closed on those grounds. Letting it through under a parity heading was
inconsistent.

Removed with it: `identity.rs`, its tests, the subcommand and its two printers,
and the four dependencies nothing else used — `bip32`, `bech32`, `ripemd`,
`bs58`. `bip39` stays for trace redaction's seed-phrase rule and `ring` for the
Computer's machine credential.

If an OpenAgents identity is needed later it should arrive with the thing that
needs to sign, and that decision can be made then.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/acp.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/delegate.rs
  • deleted crates/openagents-cli/src/identity.rs
  • modified crates/openagents-cli/src/lib.rs
  • modified crates/openagents-cli/tests/cli_test.rs
  • deleted crates/openagents-cli/tests/identity_test.rs
  • modified crates/openagents-cli/tests/private_file_race_test.rs

Diff

10 files changed, +0 -1931

Cargo.lock modified -212

@@ -166,12 +166,6 @@ version = "1.5.1"

166 166
source = "registry+https://github.com/rust-lang/crates.io-index"
167 167
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
168 168
169
[[package]]
170
name = "base16ct"
171
version = "0.2.0"
172
source = "registry+https://github.com/rust-lang/crates.io-index"
173
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
174
175 169
[[package]]
176 170
name = "base64"
177 171
version = "0.22.1"

@@ -211,25 +205,6 @@ dependencies = [

211 205
 "syn 2.0.117",
212 206
]
213 207
214
[[package]]
215
name = "bip32"
216
version = "0.5.3"
217
source = "registry+https://github.com/rust-lang/crates.io-index"
218
checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69"
219
dependencies = [
220
 "bs58",
221
 "hmac",
222
 "k256",
223
 "once_cell",
224
 "pbkdf2",
225
 "rand_core 0.6.4",
226
 "ripemd",
227
 "secp256k1",
228
 "sha2",
229
 "subtle",
230
 "zeroize",
231
]
232
233 208
[[package]]
234 209
name = "bip39"
235 210
version = "2.2.2"

@@ -288,16 +263,6 @@ dependencies = [

288 263
 "generic-array",
289 264
]
290 265
291
[[package]]
292
name = "bs58"
293
version = "0.5.1"
294
source = "registry+https://github.com/rust-lang/crates.io-index"
295
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
296
dependencies = [
297
 "sha2",
298
 "tinyvec",
299
]
300
301 266
[[package]]
302 267
name = "bumpalo"
303 268
version = "3.20.3"

@@ -500,12 +465,6 @@ dependencies = [

500 465
 "static_assertions",
501 466
]
502 467
503
[[package]]
504
name = "const-oid"
505
version = "0.9.6"
506
source = "registry+https://github.com/rust-lang/crates.io-index"
507
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
508
509 468
[[package]]
510 469
name = "core-foundation"
511 470
version = "0.10.1"

@@ -781,18 +740,6 @@ dependencies = [

781 740
 "winapi",
782 741
]
783 742
784
[[package]]
785
name = "crypto-bigint"
786
version = "0.5.5"
787
source = "registry+https://github.com/rust-lang/crates.io-index"
788
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
789
dependencies = [
790
 "generic-array",
791
 "rand_core 0.6.4",
792
 "subtle",
793
 "zeroize",
794
]
795
796 743
[[package]]
797 744
name = "crypto-common"
798 745
version = "0.1.7"

@@ -849,16 +796,6 @@ version = "2.11.0"

849 796
source = "registry+https://github.com/rust-lang/crates.io-index"
850 797
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
851 798
852
[[package]]
853
name = "der"
854
version = "0.7.10"
855
source = "registry+https://github.com/rust-lang/crates.io-index"
856
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
857
dependencies = [
858
 "const-oid",
859
 "zeroize",
860
]
861
862 799
[[package]]
863 800
name = "deranged"
864 801
version = "0.5.8"

@@ -878,9 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

878 815
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
879 816
dependencies = [
880 817
 "block-buffer",
881
 "const-oid",
882 818
 "crypto-common",
883
 "subtle",
884 819
]
885 820
886 821
[[package]]

@@ -900,43 +835,12 @@ version = "1.2.1"

900 835
source = "registry+https://github.com/rust-lang/crates.io-index"
901 836
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
902 837
903
[[package]]
904
name = "ecdsa"
905
version = "0.16.9"
906
source = "registry+https://github.com/rust-lang/crates.io-index"
907
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
908
dependencies = [
909
 "der",
910
 "digest",
911
 "elliptic-curve",
912
 "rfc6979",
913
 "signature",
914
]
915
916 838
[[package]]
917 839
name = "either"
918 840
version = "1.16.0"
919 841
source = "registry+https://github.com/rust-lang/crates.io-index"
920 842
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
921 843
922
[[package]]
923
name = "elliptic-curve"
924
version = "0.13.8"
925
source = "registry+https://github.com/rust-lang/crates.io-index"
926
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
927
dependencies = [
928
 "base16ct",
929
 "crypto-bigint",
930
 "digest",
931
 "ff",
932
 "generic-array",
933
 "group",
934
 "rand_core 0.6.4",
935
 "sec1",
936
 "subtle",
937
 "zeroize",
938
]
939
940 844
[[package]]
941 845
name = "embedded-io"
942 846
version = "0.4.0"

@@ -999,16 +903,6 @@ version = "2.5.0"

999 903
source = "registry+https://github.com/rust-lang/crates.io-index"
1000 904
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1001 905
1002
[[package]]
1003
name = "ff"
1004
version = "0.13.1"
1005
source = "registry+https://github.com/rust-lang/crates.io-index"
1006
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
1007
dependencies = [
1008
 "rand_core 0.6.4",
1009
 "subtle",
1010
]
1011
1012 906
[[package]]
1013 907
name = "filedescriptor"
1014 908
version = "0.8.3"

@@ -1153,7 +1047,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"

1153 1047
dependencies = [
1154 1048
 "typenum",
1155 1049
 "version_check",
1156
 "zeroize",
1157 1050
]
1158 1051
1159 1052
[[package]]

@@ -1211,17 +1104,6 @@ version = "0.3.3"

1211 1104
source = "registry+https://github.com/rust-lang/crates.io-index"
1212 1105
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
1213 1106
1214
[[package]]
1215
name = "group"
1216
version = "0.13.0"
1217
source = "registry+https://github.com/rust-lang/crates.io-index"
1218
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
1219
dependencies = [
1220
 "ff",
1221
 "rand_core 0.6.4",
1222
 "subtle",
1223
]
1224
1225 1107
[[package]]
1226 1108
name = "hashbrown"
1227 1109
version = "0.15.5"

@@ -1255,15 +1137,6 @@ dependencies = [

1255 1137
 "arrayvec",
1256 1138
]
1257 1139
1258
[[package]]
1259
name = "hmac"
1260
version = "0.12.1"
1261
source = "registry+https://github.com/rust-lang/crates.io-index"
1262
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1263
dependencies = [
1264
 "digest",
1265
]
1266
1267 1140
[[package]]
1268 1141
name = "html-escape"
1269 1142
version = "0.2.15"

@@ -1619,18 +1492,6 @@ dependencies = [

1619 1492
 "wasm-bindgen",
1620 1493
]
1621 1494
1622
[[package]]
1623
name = "k256"
1624
version = "0.13.4"
1625
source = "registry+https://github.com/rust-lang/crates.io-index"
1626
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
1627
dependencies = [
1628
 "cfg-if",
1629
 "ecdsa",
1630
 "elliptic-curve",
1631
 "sha2",
1632
]
1633
1634 1495
[[package]]
1635 1496
name = "lazy_static"
1636 1497
version = "1.5.0"

@@ -2066,9 +1927,7 @@ version = "0.1.0"

2066 1927
dependencies = [
2067 1928
 "async-trait",
2068 1929
 "bech32",
2069
 "bip32",
2070 1930
 "bip39",
2071
 "bs58",
2072 1931
 "clap",
2073 1932
 "clap_complete",
2074 1933
 "crossterm",

@@ -2082,7 +1941,6 @@ dependencies = [

2082 1941
 "regex",
2083 1942
 "reqwest",
2084 1943
 "ring",
2085
 "ripemd",
2086 1944
 "rustls",
2087 1945
 "serde",
2088 1946
 "serde_json",

@@ -2144,16 +2002,6 @@ version = "1.0.15"

2144 2002
source = "registry+https://github.com/rust-lang/crates.io-index"
2145 2003
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
2146 2004
2147
[[package]]
2148
name = "pbkdf2"
2149
version = "0.12.2"
2150
source = "registry+https://github.com/rust-lang/crates.io-index"
2151
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
2152
dependencies = [
2153
 "digest",
2154
 "hmac",
2155
]
2156
2157 2005
[[package]]
2158 2006
name = "percent-encoding"
2159 2007
version = "2.3.2"

@@ -2590,16 +2438,6 @@ dependencies = [

2590 2438
 "webpki-roots",
2591 2439
]
2592 2440
2593
[[package]]
2594
name = "rfc6979"
2595
version = "0.4.0"
2596
source = "registry+https://github.com/rust-lang/crates.io-index"
2597
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
2598
dependencies = [
2599
 "hmac",
2600
 "subtle",
2601
]
2602
2603 2441
[[package]]
2604 2442
name = "ring"
2605 2443
version = "0.17.14"

@@ -2614,15 +2452,6 @@ dependencies = [

2614 2452
 "windows-sys 0.52.0",
2615 2453
]
2616 2454
2617
[[package]]
2618
name = "ripemd"
2619
version = "0.1.3"
2620
source = "registry+https://github.com/rust-lang/crates.io-index"
2621
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
2622
dependencies = [
2623
 "digest",
2624
]
2625
2626 2455
[[package]]
2627 2456
name = "rustc-hash"
2628 2457
version = "2.1.2"

@@ -2738,37 +2567,6 @@ version = "1.2.0"

2738 2567
source = "registry+https://github.com/rust-lang/crates.io-index"
2739 2568
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2740 2569
2741
[[package]]
2742
name = "sec1"
2743
version = "0.7.3"
2744
source = "registry+https://github.com/rust-lang/crates.io-index"
2745
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2746
dependencies = [
2747
 "base16ct",
2748
 "der",
2749
 "generic-array",
2750
 "subtle",
2751
 "zeroize",
2752
]
2753
2754
[[package]]
2755
name = "secp256k1"
2756
version = "0.27.0"
2757
source = "registry+https://github.com/rust-lang/crates.io-index"
2758
checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f"
2759
dependencies = [
2760
 "secp256k1-sys",
2761
]
2762
2763
[[package]]
2764
name = "secp256k1-sys"
2765
version = "0.8.2"
2766
source = "registry+https://github.com/rust-lang/crates.io-index"
2767
checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99"
2768
dependencies = [
2769
 "cc",
2770
]
2771
2772 2570
[[package]]
2773 2571
name = "security-framework"
2774 2572
version = "3.7.0"

@@ -2948,16 +2746,6 @@ dependencies = [

2948 2746
 "libc",
2949 2747
]
2950 2748
2951
[[package]]
2952
name = "signature"
2953
version = "2.2.0"
2954
source = "registry+https://github.com/rust-lang/crates.io-index"
2955
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2956
dependencies = [
2957
 "digest",
2958
 "rand_core 0.6.4",
2959
]
2960
2961 2749
[[package]]
2962 2750
name = "simd-adler32"
2963 2751
version = "0.3.10"
crates/openagents-cli/Cargo.toml modified -4

@@ -31,10 +31,6 @@ eventsource-stream = "0.2.3"

31 31
unicode-width = "0.2"
32 32
unicode-segmentation = "1"
33 33
bip39 = { version = "2", features = ["rand"] }
34
bip32 = "0.5"
35
bech32 = "0.11"
36
ripemd = "0.1"
37
bs58 = { version = "0.5", features = ["check"] }
38 34
regex = "1"
39 35
zeroize = "1"
40 36
# `oa computer up` speaks the Phoenix controller socket. The same pair the
crates/openagents-cli/src/acp.rs modified -1

@@ -286,7 +286,6 @@ impl AcpHarness {

286 286
        // unconditionally.
287 287
        #[cfg(unix)]
288 288
        {
289
            use std::os::unix::process::CommandExt;
290 289
            command.process_group(0);
291 290
        }
292 291
        if let Some(environment) = &self.env {
crates/openagents-cli/src/cli.rs modified -231

@@ -52,8 +52,6 @@ pub struct Cli {

52 52
pub enum Commands {
53 53
    /// Authentication management
54 54
    Auth(AuthArgs),
55
    /// Agent and human identity operations
56
    Identity(IdentityArgs),
57 55
    /// OpenAgents issue tracker operations
58 56
    Issue(IssueArgs),
59 57
    /// OpenAgents project management

@@ -154,37 +152,6 @@ pub enum AuthAction {

154 152
    },
155 153
}
156 154
157
#[derive(Args, Debug)]
158
pub struct IdentityArgs {
159
    #[command(subcommand)]
160
    pub action: IdentityAction,
161
}
162
163
#[derive(Subcommand, Debug)]
164
pub enum IdentityAction {
165
    /// Show the public identity derived from the stored seed, and what protects it
166
    Show,
167
    /// Generate a new seed phrase and store it encrypted under the OS keychain
168
    Create {
169
        #[arg(long, default_value_t = 12, help = "Words in the new seed phrase: 12 for 128 bits, 24 for 256")]
170
        words: usize,
171
        #[arg(long, help = "Replace the stored seed. The identity and wallet it derives are lost")]
172
        force: bool,
173
    },
174
    /// Restore an existing seed phrase, read from standard input
175
    Import {
176
        #[arg(long, help = "Replace the stored seed. The identity and wallet it derives are lost")]
177
        force: bool,
178
    },
179
    /// Print the stored seed phrase
180
    Backup,
181
    /// Delete the stored seed
182
    Forget {
183
        #[arg(long, help = "Confirm that the identity and wallet are to be destroyed")]
184
        force: bool,
185
    },
186
}
187
188 155
#[derive(Args, Debug)]
189 156
pub struct IssueArgs {
190 157
    #[command(subcommand)]

@@ -1376,7 +1343,6 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1376 1343
1377 1344
    match command {
1378 1345
        Commands::Auth(auth) => run_auth(auth.action, &endpoint, &cred_store, cli.json).await,
1379
        Commands::Identity(identity) => run_identity(identity.action, cli.json),
1380 1346
        Commands::Issue(issue) => run_issue(issue.action, &api_base, token, cli.json).await,
1381 1347
        Commands::Project(project) => {
1382 1348
            run_project(project.action, &api_base, token, cli.json).await

@@ -3709,203 +3675,6 @@ async fn run_memory(action: MemoryAction, api_base: &str, token: Option<String>,

3709 3675
/// encrypted or is readable text is not something a person can infer from the
3710 3676
/// path, and the plaintext fallback is only honest if the surface that shows an
3711 3677
/// identity says so every time.
3712
fn print_identity(
3713
    identity: &crate::identity::SeedIdentity,
3714
    protection: crate::identity::SeedProtection,
3715
    seed_path: &std::path::Path,
3716
    json: bool,
3717
) {
3718
    if json {
3719
        let value = serde_json::json!({
3720
            "schema": "openagents.cli_identity.v1",
3721
            "profile": identity.profile,
3722
            "npub": identity.npub,
3723
            "nostr_public_key": identity.nostr_public_key_hex,
3724
            "nostr_derivation_path": identity.nostr_derivation_path,
3725
            "wallet_address": identity.wallet_address,
3726
            "wallet_public_key": identity.wallet_public_key_hex,
3727
            "wallet_fingerprint": identity.wallet_fingerprint_hex,
3728
            "wallet_derivation_path": identity.wallet_derivation_path,
3729
            "spending_rail": serde_json::Value::Null,
3730
            "seed_path": seed_path,
3731
            "seed_protection": protection.id(),
3732
            "seed_encrypted_at_rest": protection.encrypted_at_rest(),
3733
        });
3734
        println!("{}", value);
3735
        return;
3736
    }
3737
    println!("Identity: {}", identity.npub);
3738
    println!("  public key   {}", identity.nostr_public_key_hex);
3739
    println!("  path         {}", identity.nostr_derivation_path);
3740
    println!("Wallet:   {}", identity.wallet_address);
3741
    println!("  public key   {}", identity.wallet_public_key_hex);
3742
    println!("  fingerprint  {}", identity.wallet_fingerprint_hex);
3743
    println!("  path         {}", identity.wallet_derivation_path);
3744
    println!("Profile:  {}", identity.profile);
3745
    println!("{}", protection.describe(seed_path));
3746
}
3747
3748
fn run_identity(action: IdentityAction, json: bool) {
3749
    use crate::identity::{
3750
        derive_seed_identity, generate_seed_phrase, is_valid_seed_phrase, SeedStore,
3751
    };
3752
    let store = SeedStore::new(None);
3753
    let seed_path = store.path();
3754
3755
    let refuse_if_seed_exists = |force: bool| {
3756
        if store.present() && !force {
3757
            fail(&format!(
3758
                "A seed is already stored at {}. Back it up with `oa identity backup` first, \
3759
                 then pass --force to replace it.",
3760
                seed_path.display()
3761
            ));
3762
        }
3763
    };
3764
3765
    // Migrate before reading. A seed written before the CLI could encrypt one is
3766
    // plaintext until something moves it, and the move is the same atomic rename
3767
    // either way, so the first `show` after an upgrade protects it rather than
3768
    // waiting for the next `import`. `forget` and the write paths do not call
3769
    // this: sealing a seed that is about to be deleted or replaced would mint a
3770
    // wrapping key for nothing.
3771
    let protection_in_force = || {
3772
        store
3773
            .protect()
3774
            .unwrap_or_else(|e| fail(&e.to_string()))
3775
            .unwrap_or_else(|| {
3776
                store
3777
                    .available_protection()
3778
                    .unwrap_or_else(|e| fail(&e.to_string()))
3779
            })
3780
    };
3781
3782
    match action {
3783
        IdentityAction::Show => {
3784
            let protection = protection_in_force();
3785
            let identity = store.identity().unwrap_or_else(|e| fail(&e.to_string()));
3786
            print_identity(&identity, protection, &seed_path, json);
3787
        }
3788
        IdentityAction::Create { words, force } => {
3789
            if words != 12 && words != 24 {
3790
                fail("--words must be 12 or 24.");
3791
            }
3792
            refuse_if_seed_exists(force);
3793
3794
            let phrase = generate_seed_phrase(words).unwrap_or_else(|e| fail(&e.to_string()));
3795
            // Derive before storing: a phrase that cannot be derived from must not
3796
            // become the identity on this machine.
3797
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
3798
            let (_, stored_protection) = store.store_phrase(&phrase).unwrap_or_else(|e| {
3799
                fail(&format!(
3800
                    "The new seed could not be stored at {}: {}",
3801
                    seed_path.display(),
3802
                    e
3803
                ))
3804
            });
3805
3806
            if !json {
3807
                println!(
3808
                    "Wrote a new {}-word seed to {} (mode 0600).",
3809
                    words,
3810
                    seed_path.display()
3811
                );
3812
                println!(
3813
                    "Back it up now with `oa identity backup`. Nothing else on this machine \
3814
                     can recover it."
3815
                );
3816
            }
3817
            print_identity(&identity, stored_protection, &seed_path, json);
3818
        }
3819
        IdentityAction::Import { force } => {
3820
            refuse_if_seed_exists(force);
3821
3822
            let mut phrase = String::new();
3823
            if std::io::Read::read_to_string(&mut std::io::stdin(), &mut phrase).is_err() {
3824
                fail("No seed phrase was provided on standard input.");
3825
            }
3826
            let phrase = phrase.trim().to_string();
3827
            if phrase.is_empty() {
3828
                fail("No seed phrase was provided on standard input.");
3829
            }
3830
            // The phrase is never echoed back, not even the part that parsed.
3831
            if !is_valid_seed_phrase(&phrase) {
3832
                fail(
3833
                    "That is not a valid English BIP-39 seed phrase. Check the word count \
3834
                     (12, 15, 18, 21, or 24) and the spelling of each word.",
3835
                );
3836
            }
3837
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
3838
            let (_, stored_protection) = store.store_phrase(&phrase).unwrap_or_else(|e| {
3839
                fail(&format!(
3840
                    "The seed could not be stored at {}: {}",
3841
                    seed_path.display(),
3842
                    e
3843
                ))
3844
            });
3845
3846
            if !json {
3847
                println!("Stored the seed at {} (mode 0600).", seed_path.display());
3848
            }
3849
            print_identity(&identity, stored_protection, &seed_path, json);
3850
        }
3851
        IdentityAction::Backup => {
3852
            // The one command that prints the secret, and the one that refuses
3853
            // --json: the phrase must not land in machine-collected output.
3854
            if json {
3855
                fail(
3856
                    "`oa identity backup` does not support --json. The seed phrase must not \
3857
                     land in machine-collected output; run it without --json and copy the \
3858
                     phrase yourself.",
3859
                );
3860
            }
3861
            let protection = protection_in_force();
3862
            let phrase = match store.read_phrase() {
3863
                Ok(Some(phrase)) => phrase,
3864
                Ok(None) => fail(&crate::identity::IdentityError::NoSeed.to_string()),
3865
                Err(e) => fail(&e.to_string()),
3866
            };
3867
            println!(
3868
                "This is the only secret on this machine. Anyone holding it holds the \
3869
                 identity and the wallet."
3870
            );
3871
            // The person about to write the phrase down is the one who most
3872
            // needs to know whether the copy they are leaving behind on disk is
3873
            // encrypted or is the phrase itself.
3874
            println!("{}", protection.describe(&seed_path));
3875
            println!("{}", phrase);
3876
        }
3877
        IdentityAction::Forget { force } => {
3878
            if !force {
3879
                fail(&format!(
3880
                    "Deleting {} destroys the identity and the wallet it derives. Back the \
3881
                     phrase up with `oa identity backup`, then pass --force.",
3882
                    seed_path.display()
3883
                ));
3884
            }
3885
            let removed = store.forget().unwrap_or_else(|e| {
3886
                fail(&format!(
3887
                    "The seed at {} could not be removed: {}",
3888
                    seed_path.display(),
3889
                    e
3890
                ))
3891
            });
3892
            if json {
3893
                println!(
3894
                    "{}",
3895
                    serde_json::json!({
3896
                        "schema": "openagents.cli_identity_forget.v1",
3897
                        "removed": removed,
3898
                        "seed_path": seed_path,
3899
                    })
3900
                );
3901
            } else if removed {
3902
                println!("Removed {}.", seed_path.display());
3903
            } else {
3904
                println!("No seed was stored at {}.", seed_path.display());
3905
            }
3906
        }
3907
    }
3908
}
3909 3678
3910 3679
// ---------------------------------------------------------------------------
3911 3680
// trace
crates/openagents-cli/src/delegate.rs modified -1

@@ -710,7 +710,6 @@ async fn run_cli_child(

710 710
    // started. `process_group` is a Unix extension and is absent on Windows.
711 711
    #[cfg(unix)]
712 712
    {
713
        use std::os::unix::process::CommandExt;
714 713
        spawn.process_group(0);
715 714
    }
716 715
    let mut child = match spawn.spawn() {
crates/openagents-cli/src/identity.rs deleted -947

@@ -1,947 +0,0 @@

1
//! Cryptographic identity: one BIP-39 seed, one Nostr identity, one wallet branch.
2
//!
3
//! This is the Rust port of `packages/openagents-cli/src/seed-identity.ts`, and the
4
//! TypeScript module is the contract. The derivation profile
5
//! (`openagents.legacy_unified_nostr_spark.v1`), the NIP-06 path, the wallet path,
6
//! the English word list, and the empty BIP-39 passphrase all come from there. An
7
//! identity that differs between the two CLIs is a different account, so
8
//! `tests/identity_test.rs` pins the derivation against shared vectors: change the
9
//! derivation and the build fails rather than silently reissuing every `npub`.
10
//!
11
//! SECRETS. The mnemonic is returned from exactly one function,
12
//! [`SeedStore::read_phrase`], and derived from in memory. [`SeedIdentity`] carries
13
//! public identifiers only and is safe to print. No `nsec` and no private key is
14
//! ever written to disk or returned by `show`.
15
//!
16
//! AT REST. The seed file is `0600` inside a `0700` directory, and on a machine
17
//! with an OS keychain it holds ciphertext rather than the phrase: a 32-byte
18
//! ChaCha20-Poly1305 wrapping key lives in the keychain under service
19
//! `openagents-cli-identity`, and the file holds only the sealed envelope. That is
20
//! what stops the threats permissions never did — a backup tool, a sync client, an
21
//! agent with read access to `$HOME`, or a stolen unlocked disk image.
22
//!
23
//! Where there is no keychain — CI, a container, an unattended agent host — the
24
//! phrase is written as plaintext at `0600`, exactly as before, and [`SeedStore`]
25
//! reports [`SeedProtection::PlaintextFile`] so every surface that shows an identity
26
//! can say so. A silent fall back to plaintext would be worse than no encryption at
27
//! all, because it would read as protection that is not there. The key never goes in
28
//! the file, so the phrase exists in exactly one place either way.
29
30
use bech32::{Bech32, Hrp};
31
use bip32::{DerivationPath, XPrv};
32
use bip39::{Language, Mnemonic};
33
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305, NONCE_LEN};
34
use ring::rand::{SecureRandom, SystemRandom};
35
use ripemd::Ripemd160;
36
use serde::{Deserialize, Serialize};
37
use sha2::{Digest, Sha256};
38
use std::fmt;
39
use std::fs;
40
use std::io::Write;
41
use std::path::{Path, PathBuf};
42
use std::process::{Command, Stdio};
43
use std::str::FromStr;
44
use std::sync::Mutex;
45
use zeroize::Zeroize;
46
47
/// The frozen shared-root profile both the CLI and Pylon derive under.
48
pub const DERIVATION_PROFILE_ID: &str = "openagents.legacy_unified_nostr_spark.v1";
49
50
/// Nostr identity path: NIP-06 account zero.
51
pub const NOSTR_DERIVATION_PATH: &str = "m/44'/1237'/0'/0/0";
52
53
/// Wallet path: BIP-44 Bitcoin account zero, first external key.
54
pub const WALLET_DERIVATION_PATH: &str = "m/44'/0'/0'/0/0";
55
56
/// The frozen BIP-39 passphrase. It is empty, and a non-empty one produces a
57
/// different identity, so it is a constant here rather than an option.
58
const BIP39_PASSPHRASE: &str = "";
59
60
/// Mainnet pay-to-public-key-hash version byte, the standard BIP-44 pairing.
61
const P2PKH_VERSION: u8 = 0x00;
62
63
/// Every way identity work fails. No variant carries secret material.
64
#[derive(Debug)]
65
pub enum IdentityError {
66
    /// The phrase is not a valid English BIP-39 mnemonic. Never quotes the phrase.
67
    InvalidPhrase,
68
    /// No seed is stored, and the command needs one.
69
    NoSeed,
70
    /// A seed is already stored and the command would have overwritten it.
71
    SeedExists(PathBuf),
72
    /// Key derivation failed underneath us.
73
    Derivation(String),
74
    /// This machine has no OS keychain to hold a wrapping key. Not a failure on
75
    /// its own: it selects the plaintext store, and the caller must say so.
76
    NoKeychain,
77
    /// The keychain is here but would not answer, or answered with a record that
78
    /// is not a wrapping key. Never a reason to mint a second key: that would
79
    /// orphan the sealed seed the first one opens.
80
    Keychain(String),
81
    /// The seed on disk is sealed and the keychain holds no key for it.
82
    SealedWithoutKey(PathBuf),
83
    /// The seed on disk is sealed and the key present does not open it.
84
    Undecryptable(PathBuf),
85
    Io(std::io::Error),
86
}
87
88
impl fmt::Display for IdentityError {
89
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90
        match self {
91
            Self::InvalidPhrase => {
92
                write!(f, "The seed phrase is not a valid English BIP-39 mnemonic.")
93
            }
94
            Self::NoSeed => write!(
95
                f,
96
                "No seed is stored. Run `oa identity create` to make one, or \
97
                 `oa identity import` to restore an existing seed phrase."
98
            ),
99
            Self::SeedExists(path) => write!(
100
                f,
101
                "A seed is already stored at {}. Run `oa identity forget --force` first \
102
                 if you mean to replace it.",
103
                path.display()
104
            ),
105
            Self::Derivation(why) => write!(f, "Key derivation failed: {}", why),
106
            Self::NoKeychain => write!(
107
                f,
108
                "This machine has no OS keychain, so there is nowhere to hold a key."
109
            ),
110
            Self::Keychain(why) => write!(f, "The OS keychain could not be used: {}", why),
111
            Self::SealedWithoutKey(path) => write!(
112
                f,
113
                "The seed at {} is encrypted, and the OS keychain holds no key that opens it. \
114
                 The key does not travel with the file and is not in any backup of it. Restore \
115
                 the seed phrase with `oa identity import`.",
116
                path.display()
117
            ),
118
            Self::Undecryptable(path) => write!(
119
                f,
120
                "The seed at {} is encrypted and the key in the OS keychain does not open it. \
121
                 Restore the seed phrase with `oa identity import`.",
122
                path.display()
123
            ),
124
            Self::Io(err) => write!(f, "{}", err),
125
        }
126
    }
127
}
128
129
impl std::error::Error for IdentityError {}
130
131
impl From<std::io::Error> for IdentityError {
132
    fn from(err: std::io::Error) -> Self {
133
        Self::Io(err)
134
    }
135
}
136
137
/// The public half of one seed. Nothing here can spend, sign, or reconstruct the
138
/// seed, so every field is safe to print, store, and export.
139
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140
pub struct SeedIdentity {
141
    /// The frozen derivation profile these identifiers were produced under.
142
    pub profile: String,
143
    /// The NIP-19 `npub`, and the one cross-surface name for this identity.
144
    pub npub: String,
145
    /// The x-only 32-byte Nostr public key as hex.
146
    pub nostr_public_key_hex: String,
147
    pub nostr_derivation_path: String,
148
    /// The compressed 33-byte wallet public key as hex.
149
    pub wallet_public_key_hex: String,
150
    /// The BIP-32 key fingerprint, `HASH160(pubkey)[0..4]`, as hex.
151
    pub wallet_fingerprint_hex: String,
152
    /// The mainnet P2PKH receive address for the wallet path.
153
    pub wallet_address: String,
154
    pub wallet_derivation_path: String,
155
}
156
157
fn to_hex(bytes: &[u8]) -> String {
158
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
159
}
160
161
fn from_hex(text: &str) -> Option<Vec<u8>> {
162
    if !text.len().is_multiple_of(2) || text.is_empty() {
163
        return None;
164
    }
165
    (0..text.len())
166
        .step_by(2)
167
        .map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok())
168
        .collect()
169
}
170
171
/// Trim and collapse whitespace without changing the words themselves.
172
pub fn normalize_phrase(phrase: &str) -> String {
173
    phrase.split_whitespace().collect::<Vec<_>>().join(" ")
174
}
175
176
/// True when the phrase is a valid English BIP-39 mnemonic with a good checksum.
177
pub fn is_valid_seed_phrase(phrase: &str) -> bool {
178
    Mnemonic::parse_in_normalized(Language::English, &normalize_phrase(phrase)).is_ok()
179
}
180
181
/// Generate a fresh mnemonic from OS entropy. 12 words is 128 bits, 24 words is 256.
182
///
183
/// The entropy comes from the operating system through `bip39`'s `rand` feature, so
184
/// two runs — on one machine or two — produce different phrases. There is no seed
185
/// constant in this file, and a test asserts successive calls differ.
186
pub fn generate_seed_phrase(words: usize) -> Result<String, IdentityError> {
187
    let count = if words == 24 { 24 } else { 12 };
188
    Mnemonic::generate_in(Language::English, count)
189
        .map(|m| m.to_string())
190
        .map_err(|e| IdentityError::Derivation(e.to_string()))
191
}
192
193
/// Derive the public identity and wallet from one mnemonic.
194
///
195
/// Deterministic and side-effect free: the same phrase always yields the same `npub`
196
/// and the same wallet address, on every machine and every version. Refuses a phrase
197
/// that is not valid BIP-39 English, because deriving from a mistyped phrase would
198
/// hand back a plausible identity nobody can recover.
199
pub fn derive_seed_identity(phrase: &str) -> Result<SeedIdentity, IdentityError> {
200
    let normalized = normalize_phrase(phrase);
201
    let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized)
202
        .map_err(|_| IdentityError::InvalidPhrase)?;
203
    let seed = mnemonic.to_seed_normalized(BIP39_PASSPHRASE);
204
205
    let nostr_public_key = derive_public_key(&seed, NOSTR_DERIVATION_PATH)?;
206
    // NIP-06 keys are x-only: drop the compressed-form parity byte.
207
    let nostr_x_only = &nostr_public_key[1..];
208
209
    let wallet_public_key = derive_public_key(&seed, WALLET_DERIVATION_PATH)?;
210
    let wallet_hash160 = hash160(&wallet_public_key);
211
212
    let mut address_payload = [0u8; 21];
213
    address_payload[0] = P2PKH_VERSION;
214
    address_payload[1..].copy_from_slice(&wallet_hash160);
215
216
    let hrp = Hrp::parse("npub").map_err(|e| IdentityError::Derivation(e.to_string()))?;
217
    let npub = bech32::encode::<Bech32>(hrp, nostr_x_only)
218
        .map_err(|e| IdentityError::Derivation(e.to_string()))?;
219
220
    Ok(SeedIdentity {
221
        profile: DERIVATION_PROFILE_ID.to_string(),
222
        npub,
223
        nostr_public_key_hex: to_hex(nostr_x_only),
224
        nostr_derivation_path: NOSTR_DERIVATION_PATH.to_string(),
225
        wallet_public_key_hex: to_hex(&wallet_public_key),
226
        wallet_fingerprint_hex: to_hex(&wallet_hash160[..4]),
227
        wallet_address: bs58::encode(address_payload).with_check().into_string(),
228
        wallet_derivation_path: WALLET_DERIVATION_PATH.to_string(),
229
    })
230
}
231
232
/// Derive one compressed 33-byte secp256k1 public key at `path` from a BIP-32 seed.
233
fn derive_public_key(seed: &[u8; 64], path: &str) -> Result<[u8; 33], IdentityError> {
234
    let parsed =
235
        DerivationPath::from_str(path).map_err(|e| IdentityError::Derivation(e.to_string()))?;
236
    let xprv = XPrv::derive_from_path(seed, &parsed)
237
        .map_err(|e| IdentityError::Derivation(e.to_string()))?;
238
    Ok(xprv.public_key().to_bytes())
239
}
240
241
/// `RIPEMD160(SHA256(bytes))`, the standard Bitcoin HASH160.
242
fn hash160(bytes: &[u8]) -> [u8; 20] {
243
    let sha = Sha256::digest(bytes);
244
    let mut out = [0u8; 20];
245
    out.copy_from_slice(&Ripemd160::digest(sha));
246
    out
247
}
248
249
// ---------------------------------------------------------------------------
250
// protection at rest
251
// ---------------------------------------------------------------------------
252
253
/// What is actually protecting the stored seed. Every surface that shows an
254
/// identity reports this, because the difference between the two is the whole
255
/// security posture of the machine and a person cannot infer it from the path.
256
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257
pub enum SeedProtection {
258
    /// The file holds a sealed envelope. The key that opens it is in the OS
259
    /// keychain and never touches the identity directory.
260
    OsKeychain,
261
    /// The file holds the phrase itself at `0600`. Filesystem permissions are
262
    /// the entire protection.
263
    PlaintextFile,
264
}
265
266
impl SeedProtection {
267
    /// The stable machine name. `oa identity show --json` carries this.
268
    pub fn id(self) -> &'static str {
269
        match self {
270
            Self::OsKeychain => "os_keychain",
271
            Self::PlaintextFile => "plaintext_file",
272
        }
273
    }
274
275
    pub fn encrypted_at_rest(self) -> bool {
276
        matches!(self, Self::OsKeychain)
277
    }
278
279
    /// The sentence a person reads. It says what is protecting the seed and, for
280
    /// the plaintext store, what that protection does not cover — a fallback
281
    /// nobody is told about is the same defect as a redaction that reports
282
    /// success and leaves the secret in place.
283
    pub fn describe(self, path: &Path) -> String {
284
        match self {
285
            Self::OsKeychain => format!(
286
                "Protection: OS keychain. The seed at {} is encrypted \
287
                 ({}); the key that opens it is held by the OS keychain under service {}, \
288
                 never in the file and never in a backup of it.",
289
                path.display(),
290
                SEED_ENVELOPE_ALG,
291
                IDENTITY_KEYCHAIN_SERVICE
292
            ),
293
            Self::PlaintextFile => format!(
294
                "Protection: NONE. The seed phrase is stored as readable text at {} (mode 0600). \
295
                 No OS keychain is available here, so file permissions are the whole protection: \
296
                 they stop another local user, and they stop nothing that already runs as you — \
297
                 a backup tool, a sync client, or an agent that can read your home directory. \
298
                 Treat this file the way you would treat the phrase written on paper.",
299
                path.display()
300
            ),
301
        }
302
    }
303
}
304
305
// ---------------------------------------------------------------------------
306
// the sealed envelope
307
// ---------------------------------------------------------------------------
308
309
/// The on-disk format both CLIs read and write. Changing any of these three
310
/// constants makes one CLI unable to open the other's seed.
311
const SEED_ENVELOPE_SCHEMA: &str = "openagents.cli_identity_seed.v1";
312
const SEED_ENVELOPE_ALG: &str = "chacha20-poly1305";
313
/// Bound into the AEAD as additional data, so an envelope cannot be replayed
314
/// under a different schema.
315
const SEED_ENVELOPE_AAD: &[u8] = SEED_ENVELOPE_SCHEMA.as_bytes();
316
317
#[derive(Serialize, Deserialize)]
318
struct SeedEnvelope {
319
    schema: String,
320
    alg: String,
321
    /// The 12-byte AEAD nonce, hex. Fresh on every write.
322
    nonce: String,
323
    /// Ciphertext with the 16-byte Poly1305 tag appended, hex.
324
    ciphertext: String,
325
}
326
327
/// True when the file at hand is a sealed envelope rather than a bare mnemonic.
328
/// A BIP-39 phrase can never start with `{`, so the two formats cannot be
329
/// confused and an old plaintext seed is still recognised for migration.
330
fn looks_sealed(text: &str) -> bool {
331
    text.trim_start().starts_with('{')
332
}
333
334
fn seal_phrase(phrase: &str, key: &[u8; 32]) -> Result<String, IdentityError> {
335
    let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
336
        .map_err(|_| IdentityError::Keychain("the wrapping key is not usable".to_string()))?;
337
    let sealing = LessSafeKey::new(unbound);
338
339
    let mut nonce_bytes = [0u8; NONCE_LEN];
340
    SystemRandom::new()
341
        .fill(&mut nonce_bytes)
342
        .map_err(|_| IdentityError::Derivation("the system random source failed".to_string()))?;
343
344
    let mut in_out = phrase.as_bytes().to_vec();
345
    sealing
346
        .seal_in_place_append_tag(
347
            Nonce::assume_unique_for_key(nonce_bytes),
348
            Aad::from(SEED_ENVELOPE_AAD),
349
            &mut in_out,
350
        )
351
        .map_err(|_| IdentityError::Derivation("the seed could not be encrypted".to_string()))?;
352
353
    let envelope = SeedEnvelope {
354
        schema: SEED_ENVELOPE_SCHEMA.to_string(),
355
        alg: SEED_ENVELOPE_ALG.to_string(),
356
        nonce: to_hex(&nonce_bytes),
357
        ciphertext: to_hex(&in_out),
358
    };
359
    in_out.zeroize();
360
    serde_json::to_string(&envelope)
361
        .map_err(|e| IdentityError::Derivation(format!("the envelope could not be encoded: {e}")))
362
}
363
364
fn open_envelope(text: &str, key: &[u8; 32], path: &Path) -> Result<String, IdentityError> {
365
    let envelope: SeedEnvelope = serde_json::from_str(text.trim())
366
        .map_err(|_| IdentityError::Undecryptable(path.to_path_buf()))?;
367
    if envelope.schema != SEED_ENVELOPE_SCHEMA || envelope.alg != SEED_ENVELOPE_ALG {
368
        return Err(IdentityError::Undecryptable(path.to_path_buf()));
369
    }
370
    let nonce_bytes: [u8; NONCE_LEN] = from_hex(&envelope.nonce)
371
        .and_then(|bytes| <[u8; NONCE_LEN]>::try_from(bytes.as_slice()).ok())
372
        .ok_or_else(|| IdentityError::Undecryptable(path.to_path_buf()))?;
373
    let mut in_out = from_hex(&envelope.ciphertext)
374
        .ok_or_else(|| IdentityError::Undecryptable(path.to_path_buf()))?;
375
376
    let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
377
        .map_err(|_| IdentityError::Keychain("the wrapping key is not usable".to_string()))?;
378
    let opening = LessSafeKey::new(unbound);
379
    let opened = opening
380
        .open_in_place(
381
            Nonce::assume_unique_for_key(nonce_bytes),
382
            Aad::from(SEED_ENVELOPE_AAD),
383
            &mut in_out,
384
        )
385
        .map_err(|_| IdentityError::Undecryptable(path.to_path_buf()))?;
386
    let phrase = String::from_utf8(opened.to_vec())
387
        .map_err(|_| IdentityError::Undecryptable(path.to_path_buf()))?;
388
    in_out.zeroize();
389
    Ok(normalize_phrase(&phrase))
390
}
391
392
// ---------------------------------------------------------------------------
393
// where the wrapping key lives
394
// ---------------------------------------------------------------------------
395
396
/// The service name the OS keychain files the identity wrapping key under. It is
397
/// deliberately not `openagents-cli` (account tokens) or `openagents-cli-computer`
398
/// (machine tokens), so no two of the three can overwrite each other. The
399
/// TypeScript CLI uses the same one.
400
pub const IDENTITY_KEYCHAIN_SERVICE: &str = "openagents-cli-identity";
401
402
/// Set this to opt out of the keychain and store the phrase as plaintext at
403
/// `0600`. It exists because a keychain that prompts is worse than no keychain
404
/// on an unattended host, and because the choice should be stateable rather than
405
/// discovered. It is never selected implicitly.
406
pub const PLAINTEXT_ENV: &str = "OPENAGENTS_IDENTITY_PLAINTEXT";
407
408
/// Where the 32-byte wrapping key lives. One implementation talks to the OS
409
/// keychain; the others exist so a test exercises the real seal, open, and
410
/// migration paths without touching the developer's own keychain.
411
pub trait SeedKeyStore: Send + Sync {
412
    /// `Ok(None)` means the store answered and holds no key for this identity
413
    /// directory. `Err(NoKeychain)` means there is no store on this machine,
414
    /// which selects the plaintext file. Any other error must not be read as
415
    /// "no key": minting a second key would orphan the sealed seed.
416
    fn get(&self) -> Result<Option<[u8; 32]>, IdentityError>;
417
    /// Store the key and prove it by reading it back. A store that reports
418
    /// success without keeping the value would seal a seed nobody can open.
419
    fn put(&self, key: &[u8; 32]) -> Result<(), IdentityError>;
420
    /// Best-effort removal. Used by `forget`, so a deleted identity does not
421
    /// leave its key behind.
422
    fn delete(&self);
423
}
424
425
/// The OS keychain: `security` on macOS, `secret-tool` on Linux.
426
///
427
/// The record is keyed by the identity directory, exactly as the credential
428
/// store keys tokens by origin, so a second identity directory gets a second key
429
/// and a test with a temporary directory can never reach the developer's own.
430
pub struct OsKeychainKeyStore {
431
    account: String,
432
}
433
434
impl OsKeychainKeyStore {
435
    pub fn for_directory(directory: &Path) -> Self {
436
        Self {
437
            account: directory.display().to_string(),
438
        }
439
    }
440
441
    fn get_command(&self) -> Option<Command> {
442
        if cfg!(target_os = "macos") {
443
            let mut command = Command::new("security");
444
            command.args([
445
                "find-generic-password",
446
                "-a",
447
                &self.account,
448
                "-s",
449
                IDENTITY_KEYCHAIN_SERVICE,
450
                "-w",
451
            ]);
452
            command.stderr(Stdio::null());
453
            Some(command)
454
        } else if cfg!(target_os = "linux") {
455
            let mut command = Command::new("secret-tool");
456
            command.args([
457
                "lookup",
458
                "service",
459
                IDENTITY_KEYCHAIN_SERVICE,
460
                "account",
461
                &self.account,
462
            ]);
463
            command.stderr(Stdio::null());
464
            Some(command)
465
        } else {
466
            None
467
        }
468
    }
469
}
470
471
impl SeedKeyStore for OsKeychainKeyStore {
472
    fn get(&self) -> Result<Option<[u8; 32]>, IdentityError> {
473
        let Some(mut command) = self.get_command() else {
474
            return Err(IdentityError::NoKeychain);
475
        };
476
        // A `security` or `secret-tool` that will not start is not an empty
477
        // store: this platform has no keychain, and that is a different answer.
478
        let output = command.output().map_err(|_| IdentityError::NoKeychain)?;
479
        if !output.status.success() {
480
            return Ok(None);
481
        }
482
        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
483
        if value.is_empty() {
484
            return Ok(None);
485
        }
486
        match from_hex(&value).and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()) {
487
            Some(key) => Ok(Some(key)),
488
            // Never regenerate here. A record that is not a wrapping key means
489
            // something else wrote it, and overwriting it would make the sealed
490
            // seed permanently unopenable.
491
            None => Err(IdentityError::Keychain(format!(
492
                "the record under service {} is not an identity wrapping key",
493
                IDENTITY_KEYCHAIN_SERVICE
494
            ))),
495
        }
496
    }
497
498
    fn put(&self, key: &[u8; 32]) -> Result<(), IdentityError> {
499
        let encoded = to_hex(key);
500
        let stored = if cfg!(target_os = "macos") {
501
            // `security` reads the value from argv, so the wrapping key is
502
            // briefly visible to `ps`. The seed phrase never is: it goes to the
503
            // file sealed, and the key alone opens nothing without that file.
504
            Command::new("security")
505
                .args([
506
                    "add-generic-password",
507
                    "-U",
508
                    "-a",
509
                    &self.account,
510
                    "-s",
511
                    IDENTITY_KEYCHAIN_SERVICE,
512
                    "-w",
513
                    &encoded,
514
                ])
515
                .stdout(Stdio::null())
516
                .stderr(Stdio::null())
517
                .status()
518
                .map(|status| status.success())
519
                .map_err(|_| IdentityError::NoKeychain)?
520
        } else if cfg!(target_os = "linux") {
521
            let child = Command::new("secret-tool")
522
                .args([
523
                    "store",
524
                    "--label=OpenAgents identity",
525
                    "service",
526
                    IDENTITY_KEYCHAIN_SERVICE,
527
                    "account",
528
                    &self.account,
529
                ])
530
                .stdin(Stdio::piped())
531
                .stdout(Stdio::null())
532
                .stderr(Stdio::null())
533
                .spawn();
534
            match child {
535
                Ok(mut child) => {
536
                    if let Some(mut pipe) = child.stdin.take() {
537
                        let _ = pipe.write_all(encoded.as_bytes());
538
                    }
539
                    matches!(child.wait(), Ok(status) if status.success())
540
                }
541
                Err(_) => return Err(IdentityError::NoKeychain),
542
            }
543
        } else {
544
            return Err(IdentityError::NoKeychain);
545
        };
546
        if !stored {
547
            return Err(IdentityError::Keychain(
548
                "the OS keychain refused to store the identity wrapping key".to_string(),
549
            ));
550
        }
551
        match self.get()? {
552
            Some(read_back) if read_back == *key => Ok(()),
553
            _ => Err(IdentityError::Keychain(
554
                "the OS keychain did not return the key that was just written".to_string(),
555
            )),
556
        }
557
    }
558
559
    fn delete(&self) {
560
        if cfg!(target_os = "macos") {
561
            let _ = Command::new("security")
562
                .args([
563
                    "delete-generic-password",
564
                    "-a",
565
                    &self.account,
566
                    "-s",
567
                    IDENTITY_KEYCHAIN_SERVICE,
568
                ])
569
                .stdout(Stdio::null())
570
                .stderr(Stdio::null())
571
                .status();
572
        } else if cfg!(target_os = "linux") {
573
            let _ = Command::new("secret-tool")
574
                .args([
575
                    "clear",
576
                    "service",
577
                    IDENTITY_KEYCHAIN_SERVICE,
578
                    "account",
579
                    &self.account,
580
                ])
581
                .stdout(Stdio::null())
582
                .stderr(Stdio::null())
583
                .status();
584
        }
585
    }
586
}
587
588
/// A machine with no keychain: CI, a container, an unattended agent host. Every
589
/// call says so, which is what selects the plaintext store and the warning that
590
/// goes with it.
591
pub struct NoKeyStore;
592
593
impl SeedKeyStore for NoKeyStore {
594
    fn get(&self) -> Result<Option<[u8; 32]>, IdentityError> {
595
        Err(IdentityError::NoKeychain)
596
    }
597
    fn put(&self, _key: &[u8; 32]) -> Result<(), IdentityError> {
598
        Err(IdentityError::NoKeychain)
599
    }
600
    fn delete(&self) {}
601
}
602
603
/// A keychain that lives for the length of one test, so the seal, open, and
604
/// migration paths are exercised for real without writing to the developer's own
605
/// keychain or depending on one existing.
606
#[derive(Default)]
607
pub struct InMemoryKeyStore {
608
    key: Mutex<Option<[u8; 32]>>,
609
}
610
611
impl InMemoryKeyStore {
612
    pub fn new() -> Self {
613
        Self::default()
614
    }
615
}
616
617
impl SeedKeyStore for InMemoryKeyStore {
618
    fn get(&self) -> Result<Option<[u8; 32]>, IdentityError> {
619
        Ok(*self.key.lock().unwrap())
620
    }
621
    fn put(&self, key: &[u8; 32]) -> Result<(), IdentityError> {
622
        *self.key.lock().unwrap() = Some(*key);
623
        Ok(())
624
    }
625
    fn delete(&self) {
626
        *self.key.lock().unwrap() = None;
627
    }
628
}
629
630
/// A seed read back off disk, and what was protecting it there.
631
pub struct StoredSeed {
632
    /// The mnemonic. Secret; there is deliberately no `Debug`.
633
    pub phrase: String,
634
    pub protection: SeedProtection,
635
}
636
637
/// Where the seed lives on disk, and the only thing that touches it.
638
pub struct SeedStore {
639
    directory: PathBuf,
640
    keys: Box<dyn SeedKeyStore>,
641
}
642
643
impl SeedStore {
644
    /// `OPENAGENTS_IDENTITY_DIR` moves the store, which is how tests get an isolated
645
    /// identity without touching the developer's own. Matches the TypeScript CLI so
646
    /// both read the same seed.
647
    pub fn default_directory() -> PathBuf {
648
        match std::env::var("OPENAGENTS_IDENTITY_DIR") {
649
            Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
650
            _ => {
651
                let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
652
                PathBuf::from(home).join(".openagents").join("identity")
653
            }
654
        }
655
    }
656
657
    /// The production store: the OS keychain holds the wrapping key, unless
658
    /// [`PLAINTEXT_ENV`] says otherwise.
659
    pub fn new(directory: Option<PathBuf>) -> Self {
660
        let directory = directory.unwrap_or_else(Self::default_directory);
661
        let keys: Box<dyn SeedKeyStore> = if plaintext_requested() {
662
            Box::new(NoKeyStore)
663
        } else {
664
            Box::new(OsKeychainKeyStore::for_directory(&directory))
665
        };
666
        Self { directory, keys }
667
    }
668
669
    /// A store with the wrapping key held somewhere a test controls, so the seal,
670
    /// open, and migration paths run for real without touching the developer's
671
    /// own keychain. Mirrors `CredentialStore::isolated`.
672
    pub fn with_key_store(directory: PathBuf, keys: Box<dyn SeedKeyStore>) -> Self {
673
        Self { directory, keys }
674
    }
675
676
    /// The seed file: a sealed envelope under the OS keychain, or the mnemonic
677
    /// itself where there is no keychain. Mode `0600` either way.
678
    pub fn path(&self) -> PathBuf {
679
        self.directory.join(Self::SEED_FILE_NAME)
680
    }
681
682
    const SEED_FILE_NAME: &'static str = "seed";
683
684
    /// A staging path for one rewrite, unique to this call.
685
    ///
686
    /// It used to be the fixed `seed.tmp`. Two processes writing a seed under
687
    /// one `$HOME` then shared it: each truncated the other's staged bytes and
688
    /// removed the file out from under it, so one rewrite could rename a
689
    /// half-written envelope over the seed, or fail after the other had already
690
    /// replaced it. A seed is the one file where either outcome loses an
691
    /// identity outright. See [`crate::auth::unique_temp_path`].
692
    fn temp_path(&self) -> PathBuf {
693
        crate::auth::unique_temp_path(&self.path())
694
    }
695
696
    /// Remove staging files this directory still holds from crashed rewrites.
697
    ///
698
    /// With one fixed staging name `forget` could just delete it. Unique names
699
    /// mean a sweep instead: anything beside the seed that this store's writes
700
    /// would have named.
701
    fn sweep_temp_files(&self) {
702
        let Ok(entries) = fs::read_dir(&self.directory) else {
703
            return;
704
        };
705
        let prefix = format!(".{}.", Self::SEED_FILE_NAME);
706
        for entry in entries.flatten() {
707
            let name = entry.file_name();
708
            let name = name.to_string_lossy();
709
            if name.starts_with(&prefix) && name.ends_with(crate::auth::TEMP_SUFFIX) {
710
                let _ = fs::remove_file(entry.path());
711
            }
712
            // The name a crashed pre-sweep `oa` left behind.
713
            if name == "seed.tmp" {
714
                let _ = fs::remove_file(entry.path());
715
            }
716
        }
717
    }
718
719
    /// True when a seed is already stored. Presence only; the bytes stay on disk.
720
    pub fn present(&self) -> bool {
721
        self.path().is_file()
722
    }
723
724
    /// What a write would use on this machine right now. `Err` only when the
725
    /// keychain is present but unusable, which must not be silently downgraded
726
    /// to plaintext.
727
    pub fn available_protection(&self) -> Result<SeedProtection, IdentityError> {
728
        match self.keys.get() {
729
            Ok(_) => Ok(SeedProtection::OsKeychain),
730
            Err(IdentityError::NoKeychain) => Ok(SeedProtection::PlaintextFile),
731
            Err(other) => Err(other),
732
        }
733
    }
734
735
    /// What is protecting the seed that is on disk now, without opening it.
736
    /// `Ok(None)` when nothing is stored.
737
    pub fn protection_on_disk(&self) -> Result<Option<SeedProtection>, IdentityError> {
738
        let path = self.path();
739
        if !path.is_file() {
740
            return Ok(None);
741
        }
742
        let text = fs::read_to_string(&path)?;
743
        if text.trim().is_empty() {
744
            return Ok(None);
745
        }
746
        Ok(Some(if looks_sealed(&text) {
747
            SeedProtection::OsKeychain
748
        } else {
749
            SeedProtection::PlaintextFile
750
        }))
751
    }
752
753
    /// Read the stored seed and report what was protecting it. This and
754
    /// [`SeedStore::read_phrase`] are the only functions that return secret
755
    /// material.
756
    pub fn load(&self) -> Result<Option<StoredSeed>, IdentityError> {
757
        let path = self.path();
758
        if !path.is_file() {
759
            return Ok(None);
760
        }
761
        let text = fs::read_to_string(&path)?;
762
        if text.trim().is_empty() {
763
            return Ok(None);
764
        }
765
        if !looks_sealed(&text) {
766
            let phrase = normalize_phrase(&text);
767
            return Ok(Some(StoredSeed {
768
                phrase,
769
                protection: SeedProtection::PlaintextFile,
770
            }));
771
        }
772
        // Sealed. A keychain that cannot be read is never reported as "no seed":
773
        // that reads as an identity that vanished, and the next command would
774
        // offer to make a new one.
775
        let key = self
776
            .keys
777
            .get()?
778
            .ok_or_else(|| IdentityError::SealedWithoutKey(path.clone()))?;
779
        let phrase = open_envelope(&text, &key, &path)?;
780
        Ok(Some(StoredSeed {
781
            phrase,
782
            protection: SeedProtection::OsKeychain,
783
        }))
784
    }
785
786
    /// Read the stored mnemonic. Every caller either derives from it or hands it
787
    /// to the reader who asked for a backup.
788
    pub fn read_phrase(&self) -> Result<Option<String>, IdentityError> {
789
        Ok(self.load()?.map(|stored| stored.phrase))
790
    }
791
792
    /// Write the mnemonic under the best protection this machine has, `0600`
793
    /// inside a `0700` directory, after validating it. The validation is not
794
    /// politeness: a phrase stored here that does not validate would be an
795
    /// identity nobody can recover from its own backup.
796
    ///
797
    /// The write is atomic — staged in a sibling file and renamed over the
798
    /// target — so the phrase is never in two files at once and a crash mid-write
799
    /// leaves the previous seed intact rather than half of the new one.
800
    pub fn write_phrase(&self, phrase: &str) -> Result<PathBuf, IdentityError> {
801
        Ok(self.store_phrase(phrase)?.0)
802
    }
803
804
    /// The same write, and the protection it landed under.
805
    pub fn store_phrase(&self, phrase: &str) -> Result<(PathBuf, SeedProtection), IdentityError> {
806
        let normalized = normalize_phrase(phrase);
807
        if !is_valid_seed_phrase(&normalized) {
808
            return Err(IdentityError::InvalidPhrase);
809
        }
810
        let protection = self.available_protection()?;
811
        let body = match protection {
812
            SeedProtection::OsKeychain => {
813
                let key = match self.keys.get()? {
814
                    Some(key) => key,
815
                    None => {
816
                        let mut fresh = [0u8; 32];
817
                        SystemRandom::new().fill(&mut fresh).map_err(|_| {
818
                            IdentityError::Derivation("the system random source failed".to_string())
819
                        })?;
820
                        // Prove the keychain kept it before anything is sealed
821
                        // under it. Sealing first would produce a file no key
822
                        // opens.
823
                        self.keys.put(&fresh)?;
824
                        fresh
825
                    }
826
                };
827
                let sealed = seal_phrase(&normalized, &key)?;
828
                format!("{}\n", sealed)
829
            }
830
            SeedProtection::PlaintextFile => format!("{}\n", normalized),
831
        };
832
        let path = self.write_atomic(body.as_bytes())?;
833
        Ok((path, protection))
834
    }
835
836
    fn write_atomic(&self, bytes: &[u8]) -> Result<PathBuf, IdentityError> {
837
        fs::create_dir_all(&self.directory)?;
838
        Self::set_mode(&self.directory, 0o700)?;
839
        let path = self.path();
840
        let temp = self.temp_path();
841
        // No `remove_file` first: the name belongs to this call alone, so
842
        // anything already at it would be a surprise rather than our own
843
        // leftovers, and `create_new` inside `write_sealed` says so.
844
        if let Err(error) = Self::write_sealed(&temp, bytes) {
845
            let _ = fs::remove_file(&temp);
846
            return Err(error);
847
        }
848
        if let Err(error) = fs::rename(&temp, &path) {
849
            let _ = fs::remove_file(&temp);
850
            return Err(IdentityError::Io(error));
851
        }
852
        Self::set_mode(&path, 0o600)?;
853
        Ok(path)
854
    }
855
856
    /// Put `bytes` in a new file that is `0600` from the moment it exists.
857
    ///
858
    /// Created `0600` rather than created and then restricted: the seed must
859
    /// never be readable to the rest of the machine, not even for the instant
860
    /// between the two calls.
861
    fn write_sealed(temp: &std::path::Path, bytes: &[u8]) -> Result<(), IdentityError> {
862
        let mut options = fs::OpenOptions::new();
863
        options.write(true).create_new(true);
864
        #[cfg(unix)]
865
        {
866
            use std::os::unix::fs::OpenOptionsExt;
867
            options.mode(0o600);
868
        }
869
        let mut file = options.open(temp)?;
870
        file.write_all(bytes)?;
871
        Self::set_mode(temp, 0o600)?;
872
        Ok(())
873
    }
874
875
    /// Move a plaintext seed under the OS keychain, and report what is protecting
876
    /// it afterwards. `Ok(None)` when nothing is stored.
877
    ///
878
    /// The rewrite lands on the same path by rename, so there is never a moment
879
    /// with the phrase in two files, and the plaintext is gone the instant the
880
    /// sealed envelope arrives. On a machine with no keychain this changes
881
    /// nothing and reports [`SeedProtection::PlaintextFile`], which is what the
882
    /// caller then has to say out loud.
883
    pub fn protect(&self) -> Result<Option<SeedProtection>, IdentityError> {
884
        let Some(on_disk) = self.protection_on_disk()? else {
885
            return Ok(None);
886
        };
887
        if on_disk == SeedProtection::OsKeychain {
888
            return Ok(Some(SeedProtection::OsKeychain));
889
        }
890
        if self.available_protection()? != SeedProtection::OsKeychain {
891
            return Ok(Some(SeedProtection::PlaintextFile));
892
        }
893
        let Some(stored) = self.load()? else {
894
            return Ok(None);
895
        };
896
        let (_, protection) = self.store_phrase(&stored.phrase)?;
897
        Ok(Some(protection))
898
    }
899
900
    /// Remove the stored seed, and the wrapping key with it. Idempotent, and it
901
    /// deletes nothing else. Leaving the key behind would leave a keychain record
902
    /// for an identity that no longer exists.
903
    pub fn forget(&self) -> Result<bool, IdentityError> {
904
        let path = self.path();
905
        self.sweep_temp_files();
906
        if !path.exists() {
907
            self.keys.delete();
908
            return Ok(false);
909
        }
910
        fs::remove_file(&path)?;
911
        self.keys.delete();
912
        Ok(true)
913
    }
914
915
    /// Derive the public identity from the stored seed, or say there is none.
916
    pub fn identity(&self) -> Result<SeedIdentity, IdentityError> {
917
        match self.read_phrase()? {
918
            Some(phrase) => derive_seed_identity(&phrase),
919
            None => Err(IdentityError::NoSeed),
920
        }
921
    }
922
923
    #[cfg(unix)]
924
    fn set_mode(path: &std::path::Path, mode: u32) -> Result<(), IdentityError> {
925
        use std::os::unix::fs::PermissionsExt;
926
        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
927
        Ok(())
928
    }
929
930
    #[cfg(not(unix))]
931
    fn set_mode(_path: &std::path::Path, _mode: u32) -> Result<(), IdentityError> {
932
        Ok(())
933
    }
934
}
935
936
/// True when the environment asks for the plaintext store. Anything but an
937
/// explicit off value counts, so `=1`, `=true`, and `=yes` all work and a typo
938
/// does not silently leave the keychain on when the operator meant it off.
939
fn plaintext_requested() -> bool {
940
    match std::env::var(PLAINTEXT_ENV) {
941
        Ok(value) => {
942
            let value = value.trim().to_ascii_lowercase();
943
            !(value.is_empty() || value == "0" || value == "false" || value == "no")
944
        }
945
        Err(_) => false,
946
    }
947
}
crates/openagents-cli/src/lib.rs modified -1

@@ -25,7 +25,6 @@ pub mod errors;

25 25
pub mod fleet;
26 26
pub mod foreign_resume;
27 27
pub mod forum;
28
pub mod identity;
29 28
pub mod interactive;
30 29
pub mod markdown;
31 30
pub mod memory_client;
crates/openagents-cli/tests/cli_test.rs modified -23

@@ -7,7 +7,6 @@ mod tests {

7 7
    use openagents_cli::workspace::Isolation;
8 8
    use openagents_cli::tools::{HarnessToolRegistry, ToolCall};
9 9
    use openagents_cli::auth::CredentialStore;
10
    use openagents_cli::identity::{derive_seed_identity, SeedStore};
11 10
    use openagents_cli::tracker::{slug_from_remote_url, IssueListOptions, RepoTarget, TrackerClient};
12 11
    use openagents_cli::repo::{admitted_credential_request, parse_git_credential_request};
13 12
    use openagents_cli::box_client::BoxClient;

@@ -38,28 +37,6 @@ mod tests {

38 37
        assert!(store.find_token().unwrap().is_none());
39 38
    }
40 39
41
    /// The old assertion checked only that the strings began `npub1`/`nsec1`, which
42
    /// a `format!` over a SHA-256 digest satisfied. These assert the derivation.
43
    /// The full contract, including parity with the TypeScript CLI, is in
44
    /// `tests/identity_test.rs`.
45
    #[test]
46
    fn test_identity_generation_issue_75() {
47
        let phrase = "abandon abandon abandon abandon abandon abandon \
48
                      abandon abandon abandon abandon abandon about";
49
        let identity = derive_seed_identity(phrase).unwrap();
50
        assert_eq!(
51
            identity.npub,
52
            "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7"
53
        );
54
        assert!(derive_seed_identity("not a mnemonic").is_err());
55
56
        // Nothing is persisted until something asks for it to be.
57
        let directory = tempfile::tempdir().unwrap();
58
        let store = SeedStore::new(Some(directory.path().join("identity")));
59
        assert!(!store.present());
60
        assert!(store.identity().is_err());
61
    }
62
63 40
    /// The old assertion was `issues.is_empty() || !issues.is_empty()`, which is
64 41
    /// true of every value of every list and so held while the client asked for
65 42
    /// a route that does not exist and answered the refusal with `Ok(vec![])`.
crates/openagents-cli/tests/identity_test.rs deleted -439

@@ -1,439 +0,0 @@

1
//! Derivation parity with the TypeScript CLI, and the storage contract.
2
//!
3
//! The vectors below are the ones `packages/openagents-cli/test/seed-identity.test.ts`
4
//! freezes, which in turn are `packages/sovereign-identity/src/contract/vectors.ts`.
5
//! An `npub` that differs between the two CLIs is a different account, so a change
6
//! to the Rust derivation fails here rather than silently reissuing every identity.
7
8
use openagents_cli::identity::{
9
    derive_seed_identity, generate_seed_phrase, is_valid_seed_phrase, InMemoryKeyStore, NoKeyStore,
10
    SeedKeyStore, SeedProtection, SeedStore, DERIVATION_PROFILE_ID, NOSTR_DERIVATION_PATH,
11
    WALLET_DERIVATION_PATH,
12
};
13
use std::path::{Path, PathBuf};
14
15
/// A store whose wrapping key lives for the length of one test. Nothing here
16
/// reaches the developer's own OS keychain, and nothing depends on the machine
17
/// running the tests having one.
18
fn sealed_store(directory: &Path) -> SeedStore {
19
    SeedStore::with_key_store(
20
        directory.join("identity"),
21
        Box::new(InMemoryKeyStore::new()),
22
    )
23
}
24
25
/// A store on a machine with no keychain: CI, a container, an agent host.
26
fn headless_store(directory: &Path) -> SeedStore {
27
    SeedStore::with_key_store(directory.join("identity"), Box::new(NoKeyStore))
28
}
29
30
fn seed_bytes(path: &PathBuf) -> String {
31
    std::fs::read_to_string(path).expect("the seed file is on disk")
32
}
33
34
/// The canonical published BIP-39 test phrase. It is not a secret and never was;
35
/// it exists so a deterministic answer can be committed.
36
const TEST_PHRASE: &str =
37
    "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
38
39
const FROZEN_NPUB: &str = "npub1az708q3kd9zy6z6f44zav5ygvdwelkzspf6mtusttx47lft2z38sghk0w7";
40
const FROZEN_NOSTR_PUBKEY_HEX: &str =
41
    "e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f";
42
const FROZEN_WALLET_PUBKEY_HEX: &str =
43
    "03aaeb52dd7494c361049de67cc680e83ebcbbbdbeb13637d92cd845f70308af5e";
44
const FROZEN_WALLET_FINGERPRINT_HEX: &str = "d986ed01";
45
const FROZEN_WALLET_ADDRESS: &str = "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA";
46
47
#[test]
48
fn derives_the_frozen_identity_from_the_published_test_phrase() {
49
    let identity = derive_seed_identity(TEST_PHRASE).expect("the test phrase is valid BIP-39");
50
51
    assert_eq!(identity.profile, DERIVATION_PROFILE_ID);
52
    assert_eq!(identity.npub, FROZEN_NPUB);
53
    assert_eq!(identity.nostr_public_key_hex, FROZEN_NOSTR_PUBKEY_HEX);
54
    assert_eq!(identity.nostr_derivation_path, NOSTR_DERIVATION_PATH);
55
    assert_eq!(identity.wallet_public_key_hex, FROZEN_WALLET_PUBKEY_HEX);
56
    assert_eq!(
57
        identity.wallet_fingerprint_hex,
58
        FROZEN_WALLET_FINGERPRINT_HEX
59
    );
60
    assert_eq!(identity.wallet_address, FROZEN_WALLET_ADDRESS);
61
    assert_eq!(identity.wallet_derivation_path, WALLET_DERIVATION_PATH);
62
}
63
64
#[test]
65
fn the_npub_is_real_bech32_not_a_prefixed_hex_string() {
66
    let identity = derive_seed_identity(TEST_PHRASE).unwrap();
67
68
    // A NIP-19 npub is 63 characters. The fabricated implementation produced 37.
69
    assert_eq!(identity.npub.len(), 63, "npub: {}", identity.npub);
70
71
    // It decodes, the checksum holds, and the payload is the x-only public key.
72
    let (hrp, payload) = bech32::decode(&identity.npub).expect("npub is valid bech32");
73
    assert_eq!(hrp.as_str(), "npub");
74
    assert_eq!(payload.len(), 32);
75
    assert_eq!(
76
        payload
77
            .iter()
78
            .map(|b| format!("{:02x}", b))
79
            .collect::<String>(),
80
        FROZEN_NOSTR_PUBKEY_HEX
81
    );
82
83
    // Bech32 has no uppercase and excludes `1`, `b`, `i`, and `o` from its alphabet.
84
    let data = &identity.npub["npub1".len()..];
85
    assert!(
86
        data.chars()
87
            .all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c)),
88
        "npub payload is outside the bech32 alphabet: {}",
89
        data
90
    );
91
}
92
93
#[test]
94
fn is_insensitive_to_surrounding_whitespace_but_not_to_the_words() {
95
    let spaced = format!("  {}\n", TEST_PHRASE.replace(' ', "  "));
96
    assert_eq!(derive_seed_identity(&spaced).unwrap().npub, FROZEN_NPUB);
97
}
98
99
#[test]
100
fn refuses_a_phrase_whose_checksum_does_not_hold() {
101
    let wrong_checksum = TEST_PHRASE.replace("about", "abandon");
102
    assert!(!is_valid_seed_phrase(&wrong_checksum));
103
    assert!(derive_seed_identity(&wrong_checksum).is_err());
104
}
105
106
#[test]
107
fn generated_phrases_come_from_os_entropy_not_a_constant() {
108
    // The defect this replaces derived every key from a string literal, so two
109
    // empty HOMEs on two machines minted the same npub. Distinct phrases and
110
    // distinct identities are the property that proves the literal is gone.
111
    let first = generate_seed_phrase(12).unwrap();
112
    let second = generate_seed_phrase(12).unwrap();
113
    assert_ne!(first, second);
114
    assert_eq!(first.split_whitespace().count(), 12);
115
    assert_eq!(
116
        generate_seed_phrase(24).unwrap().split_whitespace().count(),
117
        24
118
    );
119
120
    let first_identity = derive_seed_identity(&first).unwrap();
121
    let second_identity = derive_seed_identity(&second).unwrap();
122
    assert_ne!(first_identity.npub, second_identity.npub);
123
    assert_ne!(
124
        first_identity.wallet_address,
125
        second_identity.wallet_address
126
    );
127
128
    // Every generated phrase must validate, or it could not be written back.
129
    assert!(is_valid_seed_phrase(&first));
130
}
131
132
#[test]
133
fn writes_the_phrase_0600_and_reads_it_back_unchanged() {
134
    let directory = tempfile::tempdir().unwrap();
135
    let store = sealed_store(directory.path());
136
137
    assert!(!store.present());
138
    assert!(store.read_phrase().unwrap().is_none());
139
    assert!(store.identity().is_err(), "no seed means no identity");
140
141
    let path = store.write_phrase(TEST_PHRASE).unwrap();
142
    assert!(store.present());
143
    assert_eq!(store.read_phrase().unwrap().as_deref(), Some(TEST_PHRASE));
144
145
    // Create, then show: the identity shown is the one just created.
146
    assert_eq!(store.identity().unwrap().npub, FROZEN_NPUB);
147
148
    #[cfg(unix)]
149
    {
150
        use std::os::unix::fs::PermissionsExt;
151
        let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
152
        assert_eq!(
153
            file_mode, 0o600,
154
            "seed file must not be readable by anyone else"
155
        );
156
        let dir_mode = std::fs::metadata(path.parent().unwrap())
157
            .unwrap()
158
            .permissions()
159
            .mode()
160
            & 0o777;
161
        assert_eq!(dir_mode, 0o700);
162
    }
163
}
164
165
/// The claim under test is not "encryption was called". It is that the bytes a
166
/// backup tool, a sync client, or an agent reading `$HOME` would carry away are
167
/// not the phrase, and not any word of it.
168
#[test]
169
fn the_sealed_seed_file_holds_no_word_of_the_phrase() {
170
    let directory = tempfile::tempdir().unwrap();
171
    let store = sealed_store(directory.path());
172
    let path = store.write_phrase(TEST_PHRASE).unwrap();
173
174
    let on_disk = seed_bytes(&path);
175
    assert!(
176
        !on_disk.contains(TEST_PHRASE),
177
        "the phrase is in the seed file"
178
    );
179
    assert!(
180
        !on_disk.contains("abandon"),
181
        "a phrase word is in the seed file"
182
    );
183
    assert!(
184
        !on_disk.contains("about"),
185
        "a phrase word is in the seed file"
186
    );
187
188
    // And it is the sealed envelope, not some other encoding of the same words:
189
    // a base64 or hex of the phrase would pass the checks above.
190
    assert!(on_disk.contains("chacha20-poly1305"));
191
    assert!(on_disk.contains("openagents.cli_identity_seed.v1"));
192
    assert_eq!(
193
        store.protection_on_disk().unwrap(),
194
        Some(SeedProtection::OsKeychain)
195
    );
196
    assert!(SeedProtection::OsKeychain.encrypted_at_rest());
197
198
    // The wrapping key is not in the identity directory. If it were, the file
199
    // and the key would travel together and the encryption would be theatre.
200
    for entry in std::fs::read_dir(path.parent().unwrap()).unwrap() {
201
        let entry = entry.unwrap();
202
        assert_eq!(
203
            entry.file_name(),
204
            "seed",
205
            "the identity directory holds a second file: {:?}",
206
            entry.file_name()
207
        );
208
    }
209
}
210
211
/// Two writes of the same phrase produce different bytes, which is what a fresh
212
/// nonce per write buys and what a fixed-nonce or ECB-shaped mistake would fail.
213
#[test]
214
fn every_seal_uses_a_fresh_nonce() {
215
    let directory = tempfile::tempdir().unwrap();
216
    let store = sealed_store(directory.path());
217
218
    let path = store.write_phrase(TEST_PHRASE).unwrap();
219
    let first = seed_bytes(&path);
220
    store.write_phrase(TEST_PHRASE).unwrap();
221
    let second = seed_bytes(&path);
222
223
    assert_ne!(
224
        first, second,
225
        "two seals of one phrase produced one ciphertext"
226
    );
227
    assert_eq!(store.read_phrase().unwrap().as_deref(), Some(TEST_PHRASE));
228
}
229
230
/// A sealed seed whose key is gone must say so. Reporting "no seed" would read
231
/// as an identity that vanished, and the next command would offer a new one.
232
#[test]
233
fn a_sealed_seed_without_its_key_is_an_error_not_an_absence() {
234
    let directory = tempfile::tempdir().unwrap();
235
    let keys = std::sync::Arc::new(InMemoryKeyStore::new());
236
    let store = SeedStore::with_key_store(
237
        directory.path().join("identity"),
238
        Box::new(SharedKeys(keys.clone())),
239
    );
240
    store.write_phrase(TEST_PHRASE).unwrap();
241
    keys.delete();
242
243
    assert!(store.present(), "the file is still there");
244
    let message = store.read_phrase().unwrap_err().to_string();
245
    assert!(message.contains("encrypted"), "message: {message}");
246
    assert!(!message.contains("abandon"), "the error quoted the phrase");
247
}
248
249
/// A wrapping key that does not open the envelope is not a reason to mint a new
250
/// one, which would silently orphan the seed.
251
#[test]
252
fn a_wrong_key_refuses_rather_than_returning_rubbish() {
253
    let directory = tempfile::tempdir().unwrap();
254
    let keys = std::sync::Arc::new(InMemoryKeyStore::new());
255
    let store = SeedStore::with_key_store(
256
        directory.path().join("identity"),
257
        Box::new(SharedKeys(keys.clone())),
258
    );
259
    store.write_phrase(TEST_PHRASE).unwrap();
260
    keys.put(&[7u8; 32]).unwrap();
261
262
    assert!(store.read_phrase().is_err());
263
    assert!(store.identity().is_err());
264
}
265
266
/// The headless case, stated rather than assumed: with no keychain the phrase is
267
/// on disk as text, and the store says exactly that so the CLI can print it.
268
#[test]
269
fn without_a_keychain_the_store_says_the_seed_is_plaintext() {
270
    let directory = tempfile::tempdir().unwrap();
271
    let store = headless_store(directory.path());
272
273
    let (path, protection) = store.store_phrase(TEST_PHRASE).unwrap();
274
    assert_eq!(protection, SeedProtection::PlaintextFile);
275
    assert!(!protection.encrypted_at_rest());
276
    assert_eq!(protection.id(), "plaintext_file");
277
    assert!(seed_bytes(&path).contains(TEST_PHRASE));
278
    assert_eq!(store.read_phrase().unwrap().as_deref(), Some(TEST_PHRASE));
279
280
    // The sentence a person sees must name the file and say what is not covered.
281
    let described = protection.describe(&path);
282
    assert!(
283
        described.contains(&path.display().to_string()),
284
        "{described}"
285
    );
286
    assert!(described.contains("readable text"), "{described}");
287
    assert!(described.contains("backup tool"), "{described}");
288
    assert!(!described.contains(TEST_PHRASE));
289
}
290
291
/// The migration. Start from a seed file written by the CLI that could not
292
/// encrypt one, and prove both halves: the identity is unchanged, and the
293
/// plaintext is gone.
294
#[test]
295
fn migrates_an_existing_plaintext_seed_and_leaves_no_plaintext_behind() {
296
    let directory = tempfile::tempdir().unwrap();
297
    let identity_directory = directory.path().join("identity");
298
    std::fs::create_dir_all(&identity_directory).unwrap();
299
    let path = identity_directory.join("seed");
300
301
    // Exactly what the previous CLI wrote: the phrase, one line, mode 0600.
302
    std::fs::write(&path, format!("{}\n", TEST_PHRASE)).unwrap();
303
    #[cfg(unix)]
304
    {
305
        use std::os::unix::fs::PermissionsExt;
306
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
307
    }
308
    let before = derive_seed_identity(TEST_PHRASE).unwrap();
309
310
    let store = sealed_store(directory.path());
311
    assert_eq!(
312
        store.protection_on_disk().unwrap(),
313
        Some(SeedProtection::PlaintextFile),
314
        "the fixture must start as plaintext or this test proves nothing"
315
    );
316
317
    assert_eq!(store.protect().unwrap(), Some(SeedProtection::OsKeychain));
318
319
    // The identity did not move.
320
    let after = store.identity().unwrap();
321
    assert_eq!(after, before);
322
    assert_eq!(after.npub, FROZEN_NPUB);
323
324
    // The plaintext is gone, from that file and from every other file the
325
    // migration could have left in the directory.
326
    let on_disk = seed_bytes(&path);
327
    assert!(!on_disk.contains(TEST_PHRASE));
328
    assert!(!on_disk.contains("abandon"));
329
    for entry in std::fs::read_dir(&identity_directory).unwrap() {
330
        let entry = entry.unwrap();
331
        let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
332
        assert!(
333
            !text.contains("abandon"),
334
            "{:?} still holds the phrase",
335
            entry.file_name()
336
        );
337
    }
338
339
    // Migrating twice is not a second identity, and not a second file.
340
    assert_eq!(store.protect().unwrap(), Some(SeedProtection::OsKeychain));
341
    assert_eq!(store.identity().unwrap(), before);
342
}
343
344
/// On a machine with no keychain the migration must not pretend. It reports the
345
/// plaintext store and leaves the file exactly as it found it.
346
#[test]
347
fn migration_on_a_headless_machine_reports_plaintext_rather_than_faking_it() {
348
    let directory = tempfile::tempdir().unwrap();
349
    let identity_directory = directory.path().join("identity");
350
    std::fs::create_dir_all(&identity_directory).unwrap();
351
    let path = identity_directory.join("seed");
352
    std::fs::write(&path, format!("{}\n", TEST_PHRASE)).unwrap();
353
354
    let store = headless_store(directory.path());
355
    assert_eq!(
356
        store.protect().unwrap(),
357
        Some(SeedProtection::PlaintextFile)
358
    );
359
    assert!(seed_bytes(&path).contains(TEST_PHRASE));
360
    assert_eq!(store.identity().unwrap().npub, FROZEN_NPUB);
361
}
362
363
/// A seed sealed by one CLI opens in the other. Both write the same envelope
364
/// under the same key, so this asserts the format, not the language.
365
#[test]
366
fn a_sealed_envelope_opens_from_a_second_store_holding_the_same_key() {
367
    let directory = tempfile::tempdir().unwrap();
368
    let key = [42u8; 32];
369
370
    let writer = SeedStore::with_key_store(directory.path().join("identity"), {
371
        let store = InMemoryKeyStore::new();
372
        store.put(&key).unwrap();
373
        Box::new(store)
374
    });
375
    writer.write_phrase(TEST_PHRASE).unwrap();
376
377
    let reader = SeedStore::with_key_store(directory.path().join("identity"), {
378
        let store = InMemoryKeyStore::new();
379
        store.put(&key).unwrap();
380
        Box::new(store)
381
    });
382
    assert_eq!(reader.read_phrase().unwrap().as_deref(), Some(TEST_PHRASE));
383
    assert_eq!(reader.identity().unwrap().npub, FROZEN_NPUB);
384
}
385
386
#[test]
387
fn refuses_to_store_a_phrase_that_could_not_be_recovered() {
388
    let directory = tempfile::tempdir().unwrap();
389
    let store = sealed_store(directory.path());
390
391
    assert!(store.write_phrase("not a real mnemonic at all").is_err());
392
    assert!(
393
        !store.present(),
394
        "an invalid phrase must leave no file behind"
395
    );
396
}
397
398
#[test]
399
fn forget_deletes_the_seed_the_key_and_is_idempotent() {
400
    let directory = tempfile::tempdir().unwrap();
401
    let keys = std::sync::Arc::new(InMemoryKeyStore::new());
402
    let store = SeedStore::with_key_store(
403
        directory.path().join("identity"),
404
        Box::new(SharedKeys(keys.clone())),
405
    );
406
407
    store.write_phrase(TEST_PHRASE).unwrap();
408
    assert!(
409
        keys.get().unwrap().is_some(),
410
        "a key was minted for the seal"
411
    );
412
    assert!(store.forget().unwrap(), "the first forget removes the seed");
413
    assert!(!store.present());
414
    assert!(!store.path().exists());
415
    assert!(
416
        keys.get().unwrap().is_none(),
417
        "forget left the wrapping key behind for an identity that is gone"
418
    );
419
    assert!(
420
        !store.forget().unwrap(),
421
        "a second forget reports nothing to remove"
422
    );
423
}
424
425
/// Lets a test hold on to the key store the `SeedStore` owns, so it can take the
426
/// key away or replace it mid-test.
427
struct SharedKeys(std::sync::Arc<InMemoryKeyStore>);
428
429
impl SeedKeyStore for SharedKeys {
430
    fn get(&self) -> Result<Option<[u8; 32]>, openagents_cli::identity::IdentityError> {
431
        self.0.get()
432
    }
433
    fn put(&self, key: &[u8; 32]) -> Result<(), openagents_cli::identity::IdentityError> {
434
        self.0.put(key)
435
    }
436
    fn delete(&self) {
437
        self.0.delete()
438
    }
439
}
crates/openagents-cli/tests/private_file_race_test.rs modified -72

@@ -23,7 +23,6 @@

23 23
24 24
use openagents_cli::auth::{CredentialStore, PendingDeviceAuthorization, PendingStore, Secret};
25 25
use openagents_cli::computer::{ComputerPaths, PolicyConfig};
26
use openagents_cli::identity::{generate_seed_phrase, NoKeyStore, SeedStore};
27 26
use std::io::{BufRead, BufReader, Read, Write};
28 27
use std::net::TcpListener;
29 28
use std::path::Path;

@@ -301,77 +300,6 @@ fn concurrent_pending_authorization_writes_all_succeed() {

301 300
    assert_no_staging_files_left(directory.path());
302 301
}
303 302
304
/// Concurrent seed writes all succeed and the seed that remains is one somebody
305
/// actually wrote.
306
///
307
/// The seed store staged through a fixed `seed.tmp` and removed it before each
308
/// write, so two writers could put half of one phrase and half of another in
309
/// the file that then got renamed over the seed. A seed is the one file where
310
/// that costs an identity outright: nothing recovers a mnemonic that is six
311
/// words from one wallet and six from another.
312
#[test]
313
fn concurrent_seed_writes_all_succeed_and_the_seed_still_opens() {
314
    let directory = tempfile::tempdir().expect("an identity directory");
315
    let at = directory.path().join("identity");
316
    let phrases: Vec<String> = (0..WRITERS)
317
        .map(|_| generate_seed_phrase(12).expect("a phrase"))
318
        .collect();
319
320
    let written = phrases.clone();
321
    let target = at.clone();
322
    let results = race(move |index| {
323
        SeedStore::with_key_store(target.clone(), Box::new(NoKeyStore))
324
            .write_phrase(&written[index])
325
    });
326
    for (index, result) in results.iter().enumerate() {
327
        assert!(
328
            result.is_ok(),
329
            "writer {index} was told the seed was not written: {}",
330
            result.as_ref().unwrap_err()
331
        );
332
    }
333
334
    let store = SeedStore::with_key_store(at.clone(), Box::new(NoKeyStore));
335
    let recovered = store
336
        .read_phrase()
337
        .expect("the seed opens")
338
        .expect("a seed is stored");
339
    assert!(
340
        phrases.contains(&recovered),
341
        "the stored seed is not any phrase that was written, so two writes were spliced"
342
    );
343
    #[cfg(unix)]
344
    {
345
        assert_eq!(
346
            mode_of(&store.path()),
347
            0o600,
348
            "the seed is readable to the machine"
349
        );
350
        assert_eq!(
351
            mode_of(&at),
352
            0o700,
353
            "the identity directory is open to the machine"
354
        );
355
    }
356
    assert_no_staging_files_left(&at);
357
}
358
359
/// A reader of `computer.json` never sees a policy it cannot decode, however
360
/// many writers are working on it.
361
///
362
/// `computer.json` wrote in place — truncate the target, then write it — so
363
/// between those two calls the file on disk is empty, and any `oa` that read
364
/// the policy in that window was told its own configuration is not valid JSON.
365
/// It is the policy file: the answer decides which commands the Computer will
366
/// run at all, and `load_config` is right to refuse a file it cannot read
367
/// rather than fall back to a default the owner never chose. So the refusal
368
/// lands on a reader that did nothing wrong.
369
///
370
/// Writers alone would not settle this. Two small `write` calls usually land
371
/// whole, so a test that only writes passes against the broken version. Readers
372
/// are what make the truncate window visible, and staging elsewhere and
373
/// renaming is what closes it: the target is only ever the old file or the new
374
/// one.
375 303
#[test]
376 304
fn a_reader_never_sees_a_half_written_computer_policy() {
377 305
    /// Enough passes for the truncate window to be observed if it is open.

This page updates live while a promote is in flight · changelog