Encrypt the identity seed at rest in both CLIs (#98)

7a9a80e11d33 · AtlantisPleb · · parent d6cd8d59d180

Encrypt the identity seed at rest in both CLIs (#98)

Both CLIs stored the BIP-39 phrase that derives the Nostr identity, the
wallet, and the fingerprint as plaintext at mode 0600. Permissions stop
another local user. They stop nothing that already runs as this user: a
backup tool, a sync client, an agent with read access to $HOME, or a
stolen unlocked disk image.

Option 1 from the issue. The precedent and the working code already exist
in this crate: `computer.rs` files machine tokens in the OS keychain under
`openagents-cli-computer`, `auth.rs` files account tokens under
`openagents-cli`. This adds a third service, `openagents-cli-identity`,
holding a 32-byte ChaCha20-Poly1305 wrapping key keyed by the identity
directory. The file holds the sealed envelope; the key never enters the
identity directory, so a copy of `~/.openagents/identity` is not an
identity, and the phrase is briefly nowhere in argv — `security` takes the
wrapping key, never the phrase.

The headless case is the part that had to be honest. On CI, in a
container, on an unattended agent host there is no keychain, and a silent
fall back to plaintext would read as protection that is not there. So the
fallback is stated: `identity show`, `create`, `import`, and `backup` all
print which store is in force and, for the plaintext one, the file and
what it does not protect against; `--json` carries `seed_protection` and
`seed_encrypted_at_rest`. `OPENAGENTS_IDENTITY_PLAINTEXT` selects that
store deliberately and is never applied implicitly.

Migration runs on the first read after upgrade: the sealed envelope is
renamed over the same path, so the phrase is never in two places and the
plaintext is gone the instant the envelope lands. `forget` takes the
wrapping key with it, and does not migrate a seed it is about to delete.

Both CLIs move together — they read one file at one path, so a format only
one understands makes the other a downgrade attack on it. The envelope
(`openagents.cli_identity_seed.v1`), the AEAD, the keychain service, and
the account key are one contract across `identity.rs` and
`seed-identity.ts`, verified live in both directions.

`ring` was already in the tree under `rustls`, so the AEAD and the CSPRNG
add one line to Cargo.lock and no new crate. Node's built-in
`chacha20-poly1305` needs no dependency at all.

Tests assert the property, not the call: the bytes on disk carry no word
of the phrase, two seals of one phrase differ, a sealed seed without its
key is an error rather than an absence, a wrong key refuses rather than
returning rubbish, and migration from a plaintext fixture preserves the
npub while removing the plaintext. Every test store holds its key in
memory, so no test touches a developer's own keychain. The cross-CLI
derivation vector is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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 INVARIANTS.md
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/identity.rs
  • modified crates/openagents-cli/tests/identity_test.rs
  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/src/identity-command.ts
  • modified packages/openagents-cli/src/seed-identity.ts
  • modified packages/openagents-cli/test/identity-command.test.ts
  • modified packages/openagents-cli/test/seed-identity.test.ts

Diff

11 files changed, +1956 -114

Cargo.lock modified +1

@@ -2003,6 +2003,7 @@ dependencies = [

2003 2003
 "ratatui",
2004 2004
 "regex",
2005 2005
 "reqwest 0.12.28",
2006
 "ring",
2006 2007
 "ripemd",
2007 2008
 "rustls",
2008 2009
 "serde",
INVARIANTS.md modified +25

@@ -1079,6 +1079,31 @@ come from the Freerange teardown

1079 1079
  that prints the phrase and it refuses `--json`, no command prints an `nsec` or
1080 1080
  a raw private key, and `openagents trace redact` removes seed phrases (word
1081 1081
  list-gated) and `nsec`/`xprv`-family keys from a redacted export.
1082
- The seed is encrypted at rest wherever the machine can hold a key. Both CLIs
1083
  seal the phrase with ChaCha20-Poly1305 under a 32-byte wrapping key held in
1084
  the OS keychain (`security` on macOS, `secret-tool` on Linux) under service
1085
  `openagents-cli-identity`, keyed by the identity directory; the key never
1086
  enters the identity directory, so a copy of that directory is not an identity.
1087
  The envelope (`openagents.cli_identity_seed.v1`), the AEAD, the keychain
1088
  service, and the account key are one contract across
1089
  `crates/openagents-cli/src/identity.rs` and
1090
  `packages/openagents-cli/src/seed-identity.ts`: a format only one CLI
1091
  understands makes the other a downgrade attack on it, because both read one
1092
  file at one path.
1093
- Where no keychain exists — CI, a container, an unattended agent host — the
1094
  phrase is stored as plaintext `0600` and every surface that shows an identity
1095
  must say so. `identity show`, `create`, `import`, and `backup` carry the
1096
  protection sentence, and `--json` carries `seed_protection` and
1097
  `seed_encrypted_at_rest`. A silent fall back to plaintext is prohibited: it
1098
  reads as protection that is not there. `OPENAGENTS_IDENTITY_PLAINTEXT` selects
1099
  that store deliberately and is never applied implicitly.
1100
- A plaintext seed written before this was migrated on the next `identity`
1101
  command by renaming the sealed envelope over the same path, so the phrase is
1102
  never in two places at once. Coverage is
1103
  `crates/openagents-cli/tests/identity_test.rs` and
1104
  `packages/openagents-cli/test/seed-identity.test.ts`, which assert the bytes on
1105
  disk carry no word of the phrase, and that migration preserves the `npub`
1106
  while removing the plaintext.
1082 1107
- The wallet receives; it does not spend. The spending rail is an owner decision
1083 1108
  that is not recorded, so no CLI surface may imply a spend path exists until it
1084 1109
  is.
crates/openagents-cli/Cargo.toml modified +4

@@ -48,6 +48,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"

48 48
# compilation cache this host has no use for; these four are what a
49 49
# `packet-v0` core module needs.
50 50
wasmtime = { version = "36", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] }
51
# The identity seed is sealed with ChaCha20-Poly1305 before it reaches disk.
52
# `ring` is already in the tree underneath `rustls`, so this adds an audited
53
# AEAD and CSPRNG without adding a crate to the lock.
54
ring = "0.17"
51 55
52 56
[dev-dependencies]
53 57
tempfile = "3"
crates/openagents-cli/src/cli.rs modified +48 -11

@@ -162,9 +162,9 @@ pub struct IdentityArgs {

162 162
163 163
#[derive(Subcommand, Debug)]
164 164
pub enum IdentityAction {
165
    /// Show the public identity derived from the stored seed
165
    /// Show the public identity derived from the stored seed, and what protects it
166 166
    Show,
167
    /// Generate a new seed phrase and store it 0600
167
    /// Generate a new seed phrase and store it encrypted under the OS keychain
168 168
    Create {
169 169
        #[arg(long, default_value_t = 12, help = "Words in the new seed phrase: 12 for 128 bits, 24 for 256")]
170 170
        words: usize,

@@ -3431,7 +3431,17 @@ async fn run_memory(action: MemoryAction, api_base: &str, token: Option<String>,

3431 3431
3432 3432
/// The public identity block. Public identifiers only: no seed phrase, no `nsec`,
3433 3433
/// and no private key reaches this function.
3434
fn print_identity(identity: &crate::identity::SeedIdentity, json: bool) {
3434
///
3435
/// It also carries the protection line. Whether the seed on this machine is
3436
/// encrypted or is readable text is not something a person can infer from the
3437
/// path, and the plaintext fallback is only honest if the surface that shows an
3438
/// identity says so every time.
3439
fn print_identity(
3440
    identity: &crate::identity::SeedIdentity,
3441
    protection: crate::identity::SeedProtection,
3442
    seed_path: &std::path::Path,
3443
    json: bool,
3444
) {
3435 3445
    if json {
3436 3446
        let value = serde_json::json!({
3437 3447
            "schema": "openagents.cli_identity.v1",

@@ -3444,6 +3454,9 @@ fn print_identity(identity: &crate::identity::SeedIdentity, json: bool) {

3444 3454
            "wallet_fingerprint": identity.wallet_fingerprint_hex,
3445 3455
            "wallet_derivation_path": identity.wallet_derivation_path,
3446 3456
            "spending_rail": serde_json::Value::Null,
3457
            "seed_path": seed_path,
3458
            "seed_protection": protection.id(),
3459
            "seed_encrypted_at_rest": protection.encrypted_at_rest(),
3447 3460
        });
3448 3461
        println!("{}", value);
3449 3462
        return;

@@ -3456,6 +3469,7 @@ fn print_identity(identity: &crate::identity::SeedIdentity, json: bool) {

3456 3469
    println!("  fingerprint  {}", identity.wallet_fingerprint_hex);
3457 3470
    println!("  path         {}", identity.wallet_derivation_path);
3458 3471
    println!("Profile:  {}", identity.profile);
3472
    println!("{}", protection.describe(seed_path));
3459 3473
}
3460 3474
3461 3475
fn run_identity(action: IdentityAction, json: bool) {

@@ -3475,10 +3489,28 @@ fn run_identity(action: IdentityAction, json: bool) {

3475 3489
        }
3476 3490
    };
3477 3491
3492
    // Migrate before reading. A seed written before the CLI could encrypt one is
3493
    // plaintext until something moves it, and the move is the same atomic rename
3494
    // either way, so the first `show` after an upgrade protects it rather than
3495
    // waiting for the next `import`. `forget` and the write paths do not call
3496
    // this: sealing a seed that is about to be deleted or replaced would mint a
3497
    // wrapping key for nothing.
3498
    let protection_in_force = || {
3499
        store
3500
            .protect()
3501
            .unwrap_or_else(|e| fail(&e.to_string()))
3502
            .unwrap_or_else(|| {
3503
                store
3504
                    .available_protection()
3505
                    .unwrap_or_else(|e| fail(&e.to_string()))
3506
            })
3507
    };
3508
3478 3509
    match action {
3479 3510
        IdentityAction::Show => {
3511
            let protection = protection_in_force();
3480 3512
            let identity = store.identity().unwrap_or_else(|e| fail(&e.to_string()));
3481
            print_identity(&identity, json);
3513
            print_identity(&identity, protection, &seed_path, json);
3482 3514
        }
3483 3515
        IdentityAction::Create { words, force } => {
3484 3516
            if words != 12 && words != 24 {

@@ -3490,13 +3522,13 @@ fn run_identity(action: IdentityAction, json: bool) {

3490 3522
            // Derive before storing: a phrase that cannot be derived from must not
3491 3523
            // become the identity on this machine.
3492 3524
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
3493
            store
3494
                .write_phrase(&phrase)
3495
                .unwrap_or_else(|e| fail(&format!(
3525
            let (_, stored_protection) = store.store_phrase(&phrase).unwrap_or_else(|e| {
3526
                fail(&format!(
3496 3527
                    "The new seed could not be stored at {}: {}",
3497 3528
                    seed_path.display(),
3498 3529
                    e
3499
                )));
3530
                ))
3531
            });
3500 3532
3501 3533
            if !json {
3502 3534
                println!(

@@ -3509,7 +3541,7 @@ fn run_identity(action: IdentityAction, json: bool) {

3509 3541
                     can recover it."
3510 3542
                );
3511 3543
            }
3512
            print_identity(&identity, json);
3544
            print_identity(&identity, stored_protection, &seed_path, json);
3513 3545
        }
3514 3546
        IdentityAction::Import { force } => {
3515 3547
            refuse_if_seed_exists(force);

@@ -3530,7 +3562,7 @@ fn run_identity(action: IdentityAction, json: bool) {

3530 3562
                );
3531 3563
            }
3532 3564
            let identity = derive_seed_identity(&phrase).unwrap_or_else(|e| fail(&e.to_string()));
3533
            store.write_phrase(&phrase).unwrap_or_else(|e| {
3565
            let (_, stored_protection) = store.store_phrase(&phrase).unwrap_or_else(|e| {
3534 3566
                fail(&format!(
3535 3567
                    "The seed could not be stored at {}: {}",
3536 3568
                    seed_path.display(),

@@ -3541,7 +3573,7 @@ fn run_identity(action: IdentityAction, json: bool) {

3541 3573
            if !json {
3542 3574
                println!("Stored the seed at {} (mode 0600).", seed_path.display());
3543 3575
            }
3544
            print_identity(&identity, json);
3576
            print_identity(&identity, stored_protection, &seed_path, json);
3545 3577
        }
3546 3578
        IdentityAction::Backup => {
3547 3579
            // The one command that prints the secret, and the one that refuses

@@ -3553,6 +3585,7 @@ fn run_identity(action: IdentityAction, json: bool) {

3553 3585
                     phrase yourself.",
3554 3586
                );
3555 3587
            }
3588
            let protection = protection_in_force();
3556 3589
            let phrase = match store.read_phrase() {
3557 3590
                Ok(Some(phrase)) => phrase,
3558 3591
                Ok(None) => fail(&crate::identity::IdentityError::NoSeed.to_string()),

@@ -3562,6 +3595,10 @@ fn run_identity(action: IdentityAction, json: bool) {

3562 3595
                "This is the only secret on this machine. Anyone holding it holds the \
3563 3596
                 identity and the wallet."
3564 3597
            );
3598
            // The person about to write the phrase down is the one who most
3599
            // needs to know whether the copy they are leaving behind on disk is
3600
            // encrypted or is the phrase itself.
3601
            println!("{}", protection.describe(&seed_path));
3565 3602
            println!("{}", phrase);
3566 3603
        }
3567 3604
        IdentityAction::Forget { force } => {
crates/openagents-cli/src/identity.rs modified +628 -18

@@ -11,19 +11,38 @@

11 11
//! SECRETS. The mnemonic is returned from exactly one function,
12 12
//! [`SeedStore::read_phrase`], and derived from in memory. [`SeedIdentity`] carries
13 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`; the seed file is `0600` inside a
15
//! `0700` directory.
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.
16 29
17 30
use bech32::{Bech32, Hrp};
18 31
use bip32::{DerivationPath, XPrv};
19 32
use bip39::{Language, Mnemonic};
33
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305, NONCE_LEN};
34
use ring::rand::{SecureRandom, SystemRandom};
20 35
use ripemd::Ripemd160;
21 36
use serde::{Deserialize, Serialize};
22 37
use sha2::{Digest, Sha256};
23 38
use std::fmt;
24 39
use std::fs;
25
use std::path::PathBuf;
40
use std::io::Write;
41
use std::path::{Path, PathBuf};
42
use std::process::{Command, Stdio};
26 43
use std::str::FromStr;
44
use std::sync::Mutex;
45
use zeroize::Zeroize;
27 46
28 47
/// The frozen shared-root profile both the CLI and Pylon derive under.
29 48
pub const DERIVATION_PROFILE_ID: &str = "openagents.legacy_unified_nostr_spark.v1";

@@ -52,6 +71,17 @@ pub enum IdentityError {

52 71
    SeedExists(PathBuf),
53 72
    /// Key derivation failed underneath us.
54 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),
55 85
    Io(std::io::Error),
56 86
}
57 87

@@ -73,6 +103,24 @@ impl fmt::Display for IdentityError {

73 103
                path.display()
74 104
            ),
75 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
            ),
76 124
            Self::Io(err) => write!(f, "{}", err),
77 125
        }
78 126
    }

@@ -110,6 +158,16 @@ fn to_hex(bytes: &[u8]) -> String {

110 158
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
111 159
}
112 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
113 171
/// Trim and collapse whitespace without changing the words themselves.
114 172
pub fn normalize_phrase(phrase: &str) -> String {
115 173
    phrase.split_whitespace().collect::<Vec<_>>().join(" ")

@@ -188,9 +246,398 @@ fn hash160(bytes: &[u8]) -> [u8; 20] {

188 246
    out
189 247
}
190 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
191 637
/// Where the seed lives on disk, and the only thing that touches it.
192 638
pub struct SeedStore {
193 639
    directory: PathBuf,
640
    keys: Box<dyn SeedKeyStore>,
194 641
}
195 642
196 643
impl SeedStore {

@@ -207,57 +654,207 @@ impl SeedStore {

207 654
        }
208 655
    }
209 656
657
    /// The production store: the OS keychain holds the wrapping key, unless
658
    /// [`PLAINTEXT_ENV`] says otherwise.
210 659
    pub fn new(directory: Option<PathBuf>) -> Self {
211
        Self {
212
            directory: directory.unwrap_or_else(Self::default_directory),
213
        }
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 }
214 667
    }
215 668
216
    /// The seed file itself: one line, the mnemonic, mode `0600`.
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.
217 678
    pub fn path(&self) -> PathBuf {
218 679
        self.directory.join("seed")
219 680
    }
220 681
682
    /// The path the atomic rewrite stages through. Named so `forget` and the
683
    /// write path can both make sure a crashed write leaves nothing behind.
684
    fn temp_path(&self) -> PathBuf {
685
        self.directory.join("seed.tmp")
686
    }
687
221 688
    /// True when a seed is already stored. Presence only; the bytes stay on disk.
222 689
    pub fn present(&self) -> bool {
223 690
        self.path().is_file()
224 691
    }
225 692
226
    /// Read the stored mnemonic. This is the only function that returns secret
227
    /// material, and every caller either derives from it or hands it to the reader
228
    /// who asked for a backup.
229
    pub fn read_phrase(&self) -> Result<Option<String>, IdentityError> {
693
    /// What a write would use on this machine right now. `Err` only when the
694
    /// keychain is present but unusable, which must not be silently downgraded
695
    /// to plaintext.
696
    pub fn available_protection(&self) -> Result<SeedProtection, IdentityError> {
697
        match self.keys.get() {
698
            Ok(_) => Ok(SeedProtection::OsKeychain),
699
            Err(IdentityError::NoKeychain) => Ok(SeedProtection::PlaintextFile),
700
            Err(other) => Err(other),
701
        }
702
    }
703
704
    /// What is protecting the seed that is on disk now, without opening it.
705
    /// `Ok(None)` when nothing is stored.
706
    pub fn protection_on_disk(&self) -> Result<Option<SeedProtection>, IdentityError> {
707
        let path = self.path();
708
        if !path.is_file() {
709
            return Ok(None);
710
        }
711
        let text = fs::read_to_string(&path)?;
712
        if text.trim().is_empty() {
713
            return Ok(None);
714
        }
715
        Ok(Some(if looks_sealed(&text) {
716
            SeedProtection::OsKeychain
717
        } else {
718
            SeedProtection::PlaintextFile
719
        }))
720
    }
721
722
    /// Read the stored seed and report what was protecting it. This and
723
    /// [`SeedStore::read_phrase`] are the only functions that return secret
724
    /// material.
725
    pub fn load(&self) -> Result<Option<StoredSeed>, IdentityError> {
230 726
        let path = self.path();
231 727
        if !path.is_file() {
232 728
            return Ok(None);
233 729
        }
234
        let phrase = normalize_phrase(&fs::read_to_string(&path)?);
235
        Ok(if phrase.is_empty() { None } else { Some(phrase) })
730
        let text = fs::read_to_string(&path)?;
731
        if text.trim().is_empty() {
732
            return Ok(None);
733
        }
734
        if !looks_sealed(&text) {
735
            let phrase = normalize_phrase(&text);
736
            return Ok(Some(StoredSeed {
737
                phrase,
738
                protection: SeedProtection::PlaintextFile,
739
            }));
740
        }
741
        // Sealed. A keychain that cannot be read is never reported as "no seed":
742
        // that reads as an identity that vanished, and the next command would
743
        // offer to make a new one.
744
        let key = self
745
            .keys
746
            .get()?
747
            .ok_or_else(|| IdentityError::SealedWithoutKey(path.clone()))?;
748
        let phrase = open_envelope(&text, &key, &path)?;
749
        Ok(Some(StoredSeed {
750
            phrase,
751
            protection: SeedProtection::OsKeychain,
752
        }))
236 753
    }
237 754
238
    /// Write the mnemonic, `0600` inside a `0700` directory, after validating it.
239
    /// The validation is not politeness: a phrase stored here that does not validate
240
    /// would be an identity nobody can recover from its own backup.
755
    /// Read the stored mnemonic. Every caller either derives from it or hands it
756
    /// to the reader who asked for a backup.
757
    pub fn read_phrase(&self) -> Result<Option<String>, IdentityError> {
758
        Ok(self.load()?.map(|stored| stored.phrase))
759
    }
760
761
    /// Write the mnemonic under the best protection this machine has, `0600`
762
    /// inside a `0700` directory, after validating it. The validation is not
763
    /// politeness: a phrase stored here that does not validate would be an
764
    /// identity nobody can recover from its own backup.
765
    ///
766
    /// The write is atomic — staged in a sibling file and renamed over the
767
    /// target — so the phrase is never in two files at once and a crash mid-write
768
    /// leaves the previous seed intact rather than half of the new one.
241 769
    pub fn write_phrase(&self, phrase: &str) -> Result<PathBuf, IdentityError> {
770
        Ok(self.store_phrase(phrase)?.0)
771
    }
772
773
    /// The same write, and the protection it landed under.
774
    pub fn store_phrase(&self, phrase: &str) -> Result<(PathBuf, SeedProtection), IdentityError> {
242 775
        let normalized = normalize_phrase(phrase);
243 776
        if !is_valid_seed_phrase(&normalized) {
244 777
            return Err(IdentityError::InvalidPhrase);
245 778
        }
779
        let protection = self.available_protection()?;
780
        let body = match protection {
781
            SeedProtection::OsKeychain => {
782
                let key = match self.keys.get()? {
783
                    Some(key) => key,
784
                    None => {
785
                        let mut fresh = [0u8; 32];
786
                        SystemRandom::new().fill(&mut fresh).map_err(|_| {
787
                            IdentityError::Derivation("the system random source failed".to_string())
788
                        })?;
789
                        // Prove the keychain kept it before anything is sealed
790
                        // under it. Sealing first would produce a file no key
791
                        // opens.
792
                        self.keys.put(&fresh)?;
793
                        fresh
794
                    }
795
                };
796
                let sealed = seal_phrase(&normalized, &key)?;
797
                format!("{}\n", sealed)
798
            }
799
            SeedProtection::PlaintextFile => format!("{}\n", normalized),
800
        };
801
        let path = self.write_atomic(body.as_bytes())?;
802
        Ok((path, protection))
803
    }
804
805
    fn write_atomic(&self, bytes: &[u8]) -> Result<PathBuf, IdentityError> {
246 806
        fs::create_dir_all(&self.directory)?;
247 807
        Self::set_mode(&self.directory, 0o700)?;
248 808
        let path = self.path();
249
        fs::write(&path, format!("{}\n", normalized))?;
809
        let temp = self.temp_path();
810
        let _ = fs::remove_file(&temp);
811
        fs::write(&temp, bytes)?;
812
        Self::set_mode(&temp, 0o600)?;
813
        if let Err(error) = fs::rename(&temp, &path) {
814
            let _ = fs::remove_file(&temp);
815
            return Err(IdentityError::Io(error));
816
        }
250 817
        Self::set_mode(&path, 0o600)?;
251 818
        Ok(path)
252 819
    }
253 820
254
    /// Remove the stored seed. Idempotent, and it deletes nothing else.
821
    /// Move a plaintext seed under the OS keychain, and report what is protecting
822
    /// it afterwards. `Ok(None)` when nothing is stored.
823
    ///
824
    /// The rewrite lands on the same path by rename, so there is never a moment
825
    /// with the phrase in two files, and the plaintext is gone the instant the
826
    /// sealed envelope arrives. On a machine with no keychain this changes
827
    /// nothing and reports [`SeedProtection::PlaintextFile`], which is what the
828
    /// caller then has to say out loud.
829
    pub fn protect(&self) -> Result<Option<SeedProtection>, IdentityError> {
830
        let Some(on_disk) = self.protection_on_disk()? else {
831
            return Ok(None);
832
        };
833
        if on_disk == SeedProtection::OsKeychain {
834
            return Ok(Some(SeedProtection::OsKeychain));
835
        }
836
        if self.available_protection()? != SeedProtection::OsKeychain {
837
            return Ok(Some(SeedProtection::PlaintextFile));
838
        }
839
        let Some(stored) = self.load()? else {
840
            return Ok(None);
841
        };
842
        let (_, protection) = self.store_phrase(&stored.phrase)?;
843
        Ok(Some(protection))
844
    }
845
846
    /// Remove the stored seed, and the wrapping key with it. Idempotent, and it
847
    /// deletes nothing else. Leaving the key behind would leave a keychain record
848
    /// for an identity that no longer exists.
255 849
    pub fn forget(&self) -> Result<bool, IdentityError> {
256 850
        let path = self.path();
851
        let _ = fs::remove_file(self.temp_path());
257 852
        if !path.exists() {
853
            self.keys.delete();
258 854
            return Ok(false);
259 855
        }
260 856
        fs::remove_file(&path)?;
857
        self.keys.delete();
261 858
        Ok(true)
262 859
    }
263 860

@@ -281,3 +878,16 @@ impl SeedStore {

281 878
        Ok(())
282 879
    }
283 880
}
881
882
/// True when the environment asks for the plaintext store. Anything but an
883
/// explicit off value counts, so `=1`, `=true`, and `=yes` all work and a typo
884
/// does not silently leave the keychain on when the operator meant it off.
885
fn plaintext_requested() -> bool {
886
    match std::env::var(PLAINTEXT_ENV) {
887
        Ok(value) => {
888
            let value = value.trim().to_ascii_lowercase();
889
            !(value.is_empty() || value == "0" || value == "false" || value == "no")
890
        }
891
        Err(_) => false,
892
    }
893
}
crates/openagents-cli/tests/identity_test.rs modified +306 -14

@@ -6,9 +6,30 @@

6 6
//! to the Rust derivation fails here rather than silently reissuing every identity.
7 7
8 8
use openagents_cli::identity::{
9
    derive_seed_identity, generate_seed_phrase, is_valid_seed_phrase, SeedStore,
10
    DERIVATION_PROFILE_ID, NOSTR_DERIVATION_PATH, WALLET_DERIVATION_PATH,
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,
11 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
}
12 33
13 34
/// The canonical published BIP-39 test phrase. It is not a secret and never was;
14 35
/// it exists so a deterministic answer can be committed.

@@ -32,7 +53,10 @@ fn derives_the_frozen_identity_from_the_published_test_phrase() {

32 53
    assert_eq!(identity.nostr_public_key_hex, FROZEN_NOSTR_PUBKEY_HEX);
33 54
    assert_eq!(identity.nostr_derivation_path, NOSTR_DERIVATION_PATH);
34 55
    assert_eq!(identity.wallet_public_key_hex, FROZEN_WALLET_PUBKEY_HEX);
35
    assert_eq!(identity.wallet_fingerprint_hex, FROZEN_WALLET_FINGERPRINT_HEX);
56
    assert_eq!(
57
        identity.wallet_fingerprint_hex,
58
        FROZEN_WALLET_FINGERPRINT_HEX
59
    );
36 60
    assert_eq!(identity.wallet_address, FROZEN_WALLET_ADDRESS);
37 61
    assert_eq!(identity.wallet_derivation_path, WALLET_DERIVATION_PATH);
38 62
}

@@ -49,14 +73,18 @@ fn the_npub_is_real_bech32_not_a_prefixed_hex_string() {

49 73
    assert_eq!(hrp.as_str(), "npub");
50 74
    assert_eq!(payload.len(), 32);
51 75
    assert_eq!(
52
        payload.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
76
        payload
77
            .iter()
78
            .map(|b| format!("{:02x}", b))
79
            .collect::<String>(),
53 80
        FROZEN_NOSTR_PUBKEY_HEX
54 81
    );
55 82
56 83
    // Bech32 has no uppercase and excludes `1`, `b`, `i`, and `o` from its alphabet.
57 84
    let data = &identity.npub["npub1".len()..];
58 85
    assert!(
59
        data.chars().all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c)),
86
        data.chars()
87
            .all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c)),
60 88
        "npub payload is outside the bech32 alphabet: {}",
61 89
        data
62 90
    );

@@ -84,12 +112,18 @@ fn generated_phrases_come_from_os_entropy_not_a_constant() {

84 112
    let second = generate_seed_phrase(12).unwrap();
85 113
    assert_ne!(first, second);
86 114
    assert_eq!(first.split_whitespace().count(), 12);
87
    assert_eq!(generate_seed_phrase(24).unwrap().split_whitespace().count(), 24);
115
    assert_eq!(
116
        generate_seed_phrase(24).unwrap().split_whitespace().count(),
117
        24
118
    );
88 119
89 120
    let first_identity = derive_seed_identity(&first).unwrap();
90 121
    let second_identity = derive_seed_identity(&second).unwrap();
91 122
    assert_ne!(first_identity.npub, second_identity.npub);
92
    assert_ne!(first_identity.wallet_address, second_identity.wallet_address);
123
    assert_ne!(
124
        first_identity.wallet_address,
125
        second_identity.wallet_address
126
    );
93 127
94 128
    // Every generated phrase must validate, or it could not be written back.
95 129
    assert!(is_valid_seed_phrase(&first));

@@ -98,7 +132,7 @@ fn generated_phrases_come_from_os_entropy_not_a_constant() {

98 132
#[test]
99 133
fn writes_the_phrase_0600_and_reads_it_back_unchanged() {
100 134
    let directory = tempfile::tempdir().unwrap();
101
    let store = SeedStore::new(Some(directory.path().join("identity")));
135
    let store = sealed_store(directory.path());
102 136
103 137
    assert!(!store.present());
104 138
    assert!(store.read_phrase().unwrap().is_none());

@@ -115,7 +149,10 @@ fn writes_the_phrase_0600_and_reads_it_back_unchanged() {

115 149
    {
116 150
        use std::os::unix::fs::PermissionsExt;
117 151
        let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
118
        assert_eq!(file_mode, 0o600, "seed file must not be readable by anyone else");
152
        assert_eq!(
153
            file_mode, 0o600,
154
            "seed file must not be readable by anyone else"
155
        );
119 156
        let dir_mode = std::fs::metadata(path.parent().unwrap())
120 157
            .unwrap()
121 158
            .permissions()

@@ -125,23 +162,278 @@ fn writes_the_phrase_0600_and_reads_it_back_unchanged() {

125 162
    }
126 163
}
127 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
128 386
#[test]
129 387
fn refuses_to_store_a_phrase_that_could_not_be_recovered() {
130 388
    let directory = tempfile::tempdir().unwrap();
131
    let store = SeedStore::new(Some(directory.path().join("identity")));
389
    let store = sealed_store(directory.path());
132 390
133 391
    assert!(store.write_phrase("not a real mnemonic at all").is_err());
134
    assert!(!store.present(), "an invalid phrase must leave no file behind");
392
    assert!(
393
        !store.present(),
394
        "an invalid phrase must leave no file behind"
395
    );
135 396
}
136 397
137 398
#[test]
138
fn forget_deletes_the_seed_and_is_idempotent() {
399
fn forget_deletes_the_seed_the_key_and_is_idempotent() {
139 400
    let directory = tempfile::tempdir().unwrap();
140
    let store = SeedStore::new(Some(directory.path().join("identity")));
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
    );
141 406
142 407
    store.write_phrase(TEST_PHRASE).unwrap();
408
    assert!(
409
        keys.get().unwrap().is_some(),
410
        "a key was minted for the seal"
411
    );
143 412
    assert!(store.forget().unwrap(), "the first forget removes the seed");
144 413
    assert!(!store.present());
145 414
    assert!(!store.path().exists());
146
    assert!(!store.forget().unwrap(), "a second forget reports nothing to remove");
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
    }
147 439
}
packages/openagents-cli/README.md modified +39 -5

@@ -314,11 +314,45 @@ openagents identity backup

314 314
openagents identity show
315 315
```
316 316
317
`create` writes the phrase to `~/.openagents/identity/seed` with mode `0600` and
318
does not print it. `backup` is the one command that prints it, and it refuses
319
`--json` so the phrase cannot be captured by a caller collecting machine output.
320
`show` prints public identifiers only — the `npub`, the receive address, and the
321
derivation paths — and never the phrase, an `nsec`, or a private key.
317
`create` writes the seed to `~/.openagents/identity/seed` with mode `0600` and
318
does not print the phrase. `backup` is the one command that prints it, and it
319
refuses `--json` so the phrase cannot be captured by a caller collecting machine
320
output. `show` prints public identifiers only — the `npub`, the receive address,
321
and the derivation paths — and never the phrase, an `nsec`, or a private key.
322
323
### What protects the seed at rest
324
325
On a machine with an OS keychain — macOS Keychain, or `libsecret` through
326
`secret-tool` on Linux — the file holds ciphertext, not the phrase. A 32-byte
327
ChaCha20-Poly1305 wrapping key lives in the keychain under service
328
`openagents-cli-identity`, keyed by the identity directory, and never touches the
329
file. That is what stops the reader permissions never did: a backup tool, a sync
330
client, an agent with read access to your home directory, or a stolen unlocked
331
disk image. The key does not travel with the file, so a copy of
332
`~/.openagents/identity` on another machine is not an identity.
333
334
Where there is no keychain — CI, a container, an unattended agent host — the
335
phrase is written as readable text at mode `0600`, and every command that shows
336
an identity says so:
337
338
```
339
Protection: NONE. The seed phrase is stored as readable text at
340
/home/agent/.openagents/identity/seed (mode 0600). No OS keychain is available
341
here, so file permissions are the whole protection...
342
```
343
344
`identity show --json` carries the same answer as `seed_protection`
345
(`os_keychain` or `plaintext_file`) and `seed_encrypted_at_rest`. Set
346
`OPENAGENTS_IDENTITY_PLAINTEXT=1` to choose the plaintext file deliberately, on a
347
host where a prompting keychain is worse than none. It is never selected
348
implicitly.
349
350
A seed written before the CLI could encrypt one is migrated the first time any
351
`identity` command runs: the sealed envelope is renamed over the same path, so
352
the phrase is never in two places and the plaintext is gone the instant the
353
envelope lands. On a host with no keychain the migration reports the plaintext
354
store rather than pretending. Both CLIs — this one and `oa` — write the same
355
envelope under the same key, so either opens the other's seed.
322 356
323 357
To restore an existing seed, pipe the phrase in. It is validated before anything
324 358
is written, and it is never echoed:
packages/openagents-cli/src/identity-command.ts modified +74 -24

@@ -27,15 +27,20 @@ import { InputError } from "./errors.js";

27 27
import { Output, type OutputMode } from "./output.js";
28 28
import { SecretInput } from "./secret-input.js";
29 29
import {
30
  describeSeedProtection,
30 31
  deriveSeedIdentity,
31 32
  forgetSeedPhrase,
32 33
  generateSeedPhrase,
33 34
  isValidSeedPhrase,
34
  readSeedPhrase,
35
  loadSeed,
36
  protectSeed,
37
  seedEncryptedAtRest,
35 38
  seedPath,
36 39
  seedPresent,
37
  writeSeedPhrase,
40
  seedProtectionAvailable,
41
  storeSeedPhrase,
38 42
  type SeedIdentity,
43
  type SeedProtection,
39 44
} from "./seed-identity.js";
40 45
41 46
/** The shared flags a handler reads back off the root command. */

@@ -54,7 +59,7 @@ const NO_IDENTITY =

54 59
  "No seed is stored. Run openagents identity create to make one, or " +
55 60
  "openagents identity import to restore an existing seed phrase.";
56 61
57
const identityValue = (identity: SeedIdentity) => ({
62
const identityValue = (identity: SeedIdentity, protection: SeedProtection) => ({
58 63
  schema: "openagents.cli_identity.v1",
59 64
  profile: identity.profile,
60 65
  npub: identity.npub,

@@ -65,9 +70,15 @@ const identityValue = (identity: SeedIdentity) => ({

65 70
  wallet_fingerprint: identity.walletFingerprintHex,
66 71
  wallet_derivation_path: identity.walletDerivationPath,
67 72
  spending_rail: null,
73
  seed_path: seedPath(),
74
  seed_protection: protection,
75
  seed_encrypted_at_rest: seedEncryptedAtRest(protection),
68 76
});
69 77
70
const identityHuman = (identity: SeedIdentity): ReadonlyArray<string> => [
78
const identityHuman = (
79
  identity: SeedIdentity,
80
  protection: SeedProtection,
81
): ReadonlyArray<string> => [
71 82
  `Identity: ${identity.npub}`,
72 83
  `  public key   ${identity.nostrPublicKeyHex}`,
73 84
  `  path         ${identity.nostrDerivationPath}`,

@@ -77,14 +88,42 @@ const identityHuman = (identity: SeedIdentity): ReadonlyArray<string> => [

77 88
  `  path         ${identity.walletDerivationPath}`,
78 89
  `Profile:  ${identity.profile}`,
79 90
  RAIL_NOTE,
91
  // Whether the seed on this machine is encrypted or is readable text is not
92
  // something a person can infer from the path, and the plaintext fallback is
93
  // only honest if the surface that shows an identity says so every time.
94
  describeSeedProtection(protection, seedPath()),
80 95
];
81 96
97
/**
98
 * Move a plaintext seed under the OS keychain, and report what protects it.
99
 *
100
 * Every identity command starts here. A seed written before the CLI could
101
 * encrypt one stays plaintext until something moves it, and the move is the same
102
 * atomic rename either way, so the first `show` after an upgrade protects it
103
 * rather than waiting for the next `import`.
104
 */
105
const protectionInForce = Effect.fn("Identity.protectionInForce")(function* () {
106
  return yield* Effect.try({
107
    try: () => protectSeed() ?? seedProtectionAvailable(),
108
    catch: (cause) => new InputError({ message: String(cause) }),
109
  });
110
});
111
112
/** Read the stored seed, or fail with the sentence that says what to do. */
113
const storedSeed = Effect.fn("Identity.storedSeed")(function* () {
114
  const stored = yield* Effect.try({
115
    try: () => loadSeed(),
116
    catch: (cause) => new InputError({ message: String(cause) }),
117
  });
118
  if (stored === undefined) return yield* new InputError({ message: NO_IDENTITY });
119
  return stored;
120
});
121
82 122
/** Derive from the stored seed, or fail with the sentence that says what to do. */
83 123
const storedIdentity = Effect.fn("Identity.storedIdentity")(function* () {
84
  const phrase = yield* Effect.sync(readSeedPhrase);
85
  if (phrase === undefined) return yield* new InputError({ message: NO_IDENTITY });
124
  const stored = yield* storedSeed();
86 125
  return yield* Effect.try({
87
    try: () => deriveSeedIdentity(phrase),
126
    try: () => deriveSeedIdentity(stored.phrase),
88 127
    catch: () =>
89 128
      new InputError({
90 129
        message: `The seed stored at ${seedPath()} is not a valid English BIP-39 mnemonic. Re-import the correct phrase with openagents identity import.`,

@@ -106,15 +145,19 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

106 145
    Effect.gen(function* () {
107 146
      const flags = yield* root;
108 147
      const output = yield* Output;
148
      const protection = yield* protectionInForce();
109 149
      const identity = yield* storedIdentity();
110 150
      yield* output.write(
111
        { value: identityValue(identity), human: identityHuman(identity) },
151
        {
152
          value: identityValue(identity, protection),
153
          human: identityHuman(identity, protection),
154
        },
112 155
        outputMode(flags.json),
113 156
      );
114 157
    }),
115 158
  ).pipe(
116 159
    Command.withDescription(
117
      "Show the identity and wallet this machine's seed derives. Public identifiers only: the seed phrase, the nsec, and the private keys are never printed.",
160
      "Show the identity and wallet this machine's seed derives, and what is protecting the seed at rest. Public identifiers only: the seed phrase, the nsec, and the private keys are never printed.",
118 161
    ),
119 162
  );
120 163

@@ -133,12 +176,12 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

133 176
            message: `A seed is already stored at ${seedPath()}. Back it up with openagents identity backup first, then pass --force to replace it.`,
134 177
          });
135 178
        }
136
        const identity = yield* Effect.try({
179
        const created = yield* Effect.try({
137 180
          try: () => {
138 181
            const phrase = generateSeedPhrase(words);
139 182
            const derived = deriveSeedIdentity(phrase);
140
            writeSeedPhrase(phrase);
141
            return derived;
183
            const { protection } = storeSeedPhrase(phrase);
184
            return { identity: derived, protection };
142 185
          },
143 186
          catch: (cause) =>
144 187
            new InputError({

@@ -147,11 +190,11 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

147 190
        });
148 191
        yield* output.write(
149 192
          {
150
            value: { ...identityValue(identity), created: true, seed_path: seedPath() },
193
            value: { ...identityValue(created.identity, created.protection), created: true },
151 194
            human: [
152 195
              `Wrote a new ${words}-word seed to ${seedPath()} (mode 0600).`,
153 196
              "Back it up now with openagents identity backup. Nothing else on this machine can recover it.",
154
              ...identityHuman(identity),
197
              ...identityHuman(created.identity, created.protection),
155 198
            ],
156 199
          },
157 200
          outputMode(flags.json),

@@ -159,7 +202,7 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

159 202
      }),
160 203
  ).pipe(
161 204
    Command.withDescription(
162
      "Generate a seed phrase and store it 0600. The phrase itself is not printed; run openagents identity backup to see it.",
205
      "Generate a seed phrase and store it encrypted under the OS keychain, or 0600 plaintext where there is no keychain. The phrase itself is not printed; run openagents identity backup to see it.",
163 206
    ),
164 207
  );
165 208

@@ -181,11 +224,11 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

181 224
            "That is not a valid English BIP-39 seed phrase. Check the word count (12, 15, 18, 21, or 24) and the spelling of each word.",
182 225
        });
183 226
      }
184
      const identity = yield* Effect.try({
227
      const imported = yield* Effect.try({
185 228
        try: () => {
186 229
          const derived = deriveSeedIdentity(phrase);
187
          writeSeedPhrase(phrase);
188
          return derived;
230
          const { protection } = storeSeedPhrase(phrase);
231
          return { identity: derived, protection };
189 232
        },
190 233
        catch: (cause) =>
191 234
          new InputError({

@@ -194,15 +237,18 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

194 237
      });
195 238
      yield* output.write(
196 239
        {
197
          value: { ...identityValue(identity), imported: true, seed_path: seedPath() },
198
          human: [`Stored the seed at ${seedPath()} (mode 0600).`, ...identityHuman(identity)],
240
          value: { ...identityValue(imported.identity, imported.protection), imported: true },
241
          human: [
242
            `Stored the seed at ${seedPath()} (mode 0600).`,
243
            ...identityHuman(imported.identity, imported.protection),
244
          ],
199 245
        },
200 246
        outputMode(flags.json),
201 247
      );
202 248
    }),
203 249
  ).pipe(
204 250
    Command.withDescription(
205
      "Read a seed phrase from standard input and store it 0600. The phrase is never echoed, and an invalid phrase is rejected before anything is written.",
251
      "Read a seed phrase from standard input and store it encrypted under the OS keychain, or 0600 plaintext where there is no keychain. The phrase is never echoed, and an invalid phrase is rejected before anything is written.",
206 252
    ),
207 253
  );
208 254

@@ -216,14 +262,18 @@ export const makeIdentityCommand = <R>(root: Effect.Effect<SharedFlags, never, R

216 262
            "openagents identity backup does not support --json. The seed phrase must not land in machine-collected output; run it without --json and copy the phrase yourself.",
217 263
        });
218 264
      }
219
      const phrase = yield* Effect.sync(readSeedPhrase);
220
      if (phrase === undefined) return yield* new InputError({ message: NO_IDENTITY });
265
      const protection = yield* protectionInForce();
266
      const stored = yield* storedSeed();
221 267
      yield* output.write(
222 268
        {
223 269
          value: { schema: "openagents.cli_identity_backup.v1" },
224 270
          human: [
225 271
            "This is the only secret on this machine. Anyone holding it holds the identity and the wallet.",
226
            phrase,
272
            // The person about to write the phrase down is the one who most
273
            // needs to know whether the copy left behind on disk is encrypted
274
            // or is the phrase itself.
275
            describeSeedProtection(protection, seedPath()),
276
            stored.phrase,
227 277
          ],
228 278
        },
229 279
        "human",
packages/openagents-cli/src/seed-identity.ts modified +530 -23

@@ -23,10 +23,30 @@

23 23
 * `@noble/hashes` 1.7.1, `@scure/bip32` 1.6.2, `@scure/bip39` 1.5.4.
24 24
 *
25 25
 * SECRETS. This module returns the mnemonic from exactly one function,
26
 * {@link readSeedPhrase}, and derives from it in memory. It never logs, never
27
 * returns an `nsec` or a raw private key, and writes the seed file `0600` inside
28
 * a `0700` directory. The public manifest {@link SeedIdentity} carries public
29
 * identifiers only and is safe to print, store, and export.
26
 * {@link readSeedPhrase}, and derives from it in memory. It never logs and never
27
 * returns an `nsec` or a raw private key. The public manifest
28
 * {@link SeedIdentity} carries public identifiers only and is safe to print,
29
 * store, and export.
30
 *
31
 * AT REST. The seed file is `0600` inside a `0700` directory, and on a machine
32
 * with an OS keychain it holds ciphertext rather than the phrase: a 32-byte
33
 * ChaCha20-Poly1305 wrapping key lives in the keychain under service
34
 * `openagents-cli-identity`, and the file holds only the sealed envelope. That is
35
 * what stops the threats permissions never did — a backup tool, a sync client, an
36
 * agent with read access to `$HOME`, or a stolen unlocked disk image.
37
 *
38
 * Where there is no keychain — CI, a container, an unattended agent host — the
39
 * phrase is written as plaintext at `0600`, exactly as before, and every read
40
 * reports {@link SeedProtection} `plaintext_file` so the surfaces that show an
41
 * identity can say so. A silent fall back to plaintext would be worse than no
42
 * encryption at all, because it would read as protection that is not there. The
43
 * key never goes in the file, so the phrase exists in exactly one place either
44
 * way.
45
 *
46
 * The envelope format, the AEAD, the keychain service, and the account key are
47
 * the same in `crates/openagents-cli/src/identity.rs`. The two CLIs read one file
48
 * at one path, so a format only one of them understands makes the other a
49
 * downgrade attack on it.
30 50
 *
31 51
 * The spending rail is deliberately absent. Which rail the wallet spends over —
32 52
 * self-custodial MDK/LDK, or the deterministic Spark rail Pylon v1.0 used — is

@@ -34,7 +54,17 @@

34 54
 * branch and stops. Receiving identifiers are rail-independent; spending is not.
35 55
 */
36 56
37
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
57
import { spawnSync } from "node:child_process";
58
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
59
import {
60
  chmodSync,
61
  existsSync,
62
  mkdirSync,
63
  readFileSync,
64
  renameSync,
65
  rmSync,
66
  writeFileSync,
67
} from "node:fs";
38 68
import { homedir } from "node:os";
39 69
import { join } from "node:path";
40 70

@@ -155,46 +185,523 @@ export const seedDirectory = (): string => {

155 185
    : join(homedir(), ".openagents", "identity");
156 186
};
157 187
158
/** The seed file itself: one line, the mnemonic, mode `0600`. */
188
/**
189
 * The seed file: a sealed envelope under the OS keychain, or the mnemonic itself
190
 * where there is no keychain. Mode `0600` either way.
191
 */
159 192
export const seedPath = (): string => join(seedDirectory(), "seed");
160 193
194
/** Where the atomic rewrite stages, so the phrase is never in two files. */
195
const seedTempPath = (): string => join(seedDirectory(), "seed.tmp");
196
161 197
/** True when a seed is already stored. Presence only; the bytes stay on disk. */
162 198
export const seedPresent = (): boolean => existsSync(seedPath());
163 199
200
// ---------------------------------------------------------------------------
201
// protection at rest
202
// ---------------------------------------------------------------------------
203
204
/**
205
 * What is actually protecting the stored seed. Every surface that shows an
206
 * identity reports this, because the difference between the two is the whole
207
 * security posture of the machine and a person cannot infer it from the path.
208
 */
209
export type SeedProtection = "os_keychain" | "plaintext_file";
210
211
/** True when the file on disk is ciphertext rather than the phrase. */
212
export const seedEncryptedAtRest = (protection: SeedProtection): boolean =>
213
  protection === "os_keychain";
214
215
/**
216
 * The sentence a person reads. It says what is protecting the seed and, for the
217
 * plaintext store, what that protection does not cover — a fallback nobody is
218
 * told about is the same defect as a redaction that reports success and leaves
219
 * the secret in place.
220
 */
221
export const describeSeedProtection = (protection: SeedProtection, path: string): string =>
222
  protection === "os_keychain"
223
    ? `Protection: OS keychain. The seed at ${path} is encrypted (${SEED_ENVELOPE_ALG}); ` +
224
      `the key that opens it is held by the OS keychain under service ${IDENTITY_KEYCHAIN_SERVICE}, ` +
225
      "never in the file and never in a backup of it."
226
    : `Protection: NONE. The seed phrase is stored as readable text at ${path} (mode 0600). ` +
227
      "No OS keychain is available here, so file permissions are the whole protection: " +
228
      "they stop another local user, and they stop nothing that already runs as you — " +
229
      "a backup tool, a sync client, or an agent that can read your home directory. " +
230
      "Treat this file the way you would treat the phrase written on paper.";
231
232
// ---------------------------------------------------------------------------
233
// the sealed envelope
234
// ---------------------------------------------------------------------------
235
236
/**
237
 * The on-disk format both CLIs read and write. Changing any of these three
238
 * constants makes one CLI unable to open the other's seed.
239
 */
240
export const SEED_ENVELOPE_SCHEMA = "openagents.cli_identity_seed.v1";
241
export const SEED_ENVELOPE_ALG = "chacha20-poly1305";
242
/** Bound into the AEAD, so an envelope cannot be replayed under another schema. */
243
const SEED_ENVELOPE_AAD = Buffer.from(SEED_ENVELOPE_SCHEMA, "utf8");
244
const SEED_NONCE_BYTES = 12;
245
const SEED_TAG_BYTES = 16;
246
const SEED_KEY_BYTES = 32;
247
248
interface SeedEnvelope {
249
  readonly schema: string;
250
  readonly alg: string;
251
  /** The 12-byte AEAD nonce, hex. Fresh on every write. */
252
  readonly nonce: string;
253
  /** Ciphertext with the 16-byte Poly1305 tag appended, hex. */
254
  readonly ciphertext: string;
255
}
256
257
/**
258
 * True when the file at hand is a sealed envelope rather than a bare mnemonic. A
259
 * BIP-39 phrase can never start with `{`, so the two formats cannot be confused
260
 * and an old plaintext seed is still recognised for migration.
261
 */
262
const looksSealed = (text: string): boolean => text.trimStart().startsWith("{");
263
264
const sealPhrase = (phrase: string, key: Uint8Array): string => {
265
  const nonce = randomBytes(SEED_NONCE_BYTES);
266
  const cipher = createCipheriv(SEED_ENVELOPE_ALG, key, nonce, {
267
    authTagLength: SEED_TAG_BYTES,
268
  });
269
  cipher.setAAD(SEED_ENVELOPE_AAD, { plaintextLength: Buffer.byteLength(phrase, "utf8") });
270
  const body = Buffer.concat([cipher.update(phrase, "utf8"), cipher.final()]);
271
  const envelope: SeedEnvelope = {
272
    schema: SEED_ENVELOPE_SCHEMA,
273
    alg: SEED_ENVELOPE_ALG,
274
    nonce: nonce.toString("hex"),
275
    ciphertext: Buffer.concat([body, cipher.getAuthTag()]).toString("hex"),
276
  };
277
  return JSON.stringify(envelope);
278
};
279
280
const undecryptable = (path: string): Error =>
281
  new Error(
282
    `The seed at ${path} is encrypted and the key in the OS keychain does not open it. ` +
283
      "Restore the seed phrase with openagents identity import.",
284
  );
285
286
const openEnvelope = (text: string, key: Uint8Array, path: string): string => {
287
  let envelope: SeedEnvelope;
288
  try {
289
    envelope = JSON.parse(text.trim()) as SeedEnvelope;
290
  } catch {
291
    throw undecryptable(path);
292
  }
293
  if (envelope.schema !== SEED_ENVELOPE_SCHEMA || envelope.alg !== SEED_ENVELOPE_ALG) {
294
    throw undecryptable(path);
295
  }
296
  const nonce = Buffer.from(envelope.nonce, "hex");
297
  const sealed = Buffer.from(envelope.ciphertext, "hex");
298
  if (nonce.length !== SEED_NONCE_BYTES || sealed.length <= SEED_TAG_BYTES)
299
    throw undecryptable(path);
300
  const body = sealed.subarray(0, sealed.length - SEED_TAG_BYTES);
301
  const tag = sealed.subarray(sealed.length - SEED_TAG_BYTES);
302
  try {
303
    const decipher = createDecipheriv(SEED_ENVELOPE_ALG, key, nonce, {
304
      authTagLength: SEED_TAG_BYTES,
305
    });
306
    decipher.setAAD(SEED_ENVELOPE_AAD, { plaintextLength: body.length });
307
    decipher.setAuthTag(tag);
308
    return normalize(Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8"));
309
  } catch {
310
    throw undecryptable(path);
311
  }
312
};
313
314
// ---------------------------------------------------------------------------
315
// where the wrapping key lives
316
// ---------------------------------------------------------------------------
317
318
/**
319
 * The service name the OS keychain files the identity wrapping key under. It is
320
 * deliberately not `openagents-cli` (account tokens) or `openagents-cli-computer`
321
 * (machine tokens), so no two of the three can overwrite each other. The Rust CLI
322
 * uses the same one.
323
 */
324
export const IDENTITY_KEYCHAIN_SERVICE = "openagents-cli-identity";
325
326
/**
327
 * Set this to opt out of the keychain and store the phrase as plaintext at
328
 * `0600`. It exists because a keychain that prompts is worse than no keychain on
329
 * an unattended host, and because the choice should be stateable rather than
330
 * discovered. It is never selected implicitly.
331
 */
332
export const PLAINTEXT_ENV = "OPENAGENTS_IDENTITY_PLAINTEXT";
333
334
/**
335
 * Where the 32-byte wrapping key lives. One implementation talks to the OS
336
 * keychain; the others exist so a test exercises the real seal, open, and
337
 * migration paths without touching the developer's own keychain.
338
 */
339
export interface SeedKeyStore {
340
  /** False when this machine has no keychain, which selects the plaintext file. */
341
  readonly available: () => boolean;
342
  /**
343
   * `undefined` means the store answered and holds no key for this identity
344
   * directory. A throw must never be read as "no key": minting a second one
345
   * would orphan the sealed seed.
346
   */
347
  readonly get: () => Uint8Array | undefined;
348
  /**
349
   * Store the key and prove it by reading it back. A store that reports success
350
   * without keeping the value would seal a seed nobody can open.
351
   */
352
  readonly put: (key: Uint8Array) => void;
353
  /** Best-effort removal, so a forgotten identity leaves no key behind. */
354
  readonly delete: () => void;
355
}
356
357
interface KeychainCommand {
358
  readonly command: string;
359
  readonly args: ReadonlyArray<string>;
360
  readonly input?: string;
361
}
362
363
/**
364
 * The command that reads, writes, or clears the wrapping key. Exported so a test
365
 * can assert the shape without a keychain, and so the two CLIs can be compared
366
 * side by side.
367
 */
368
export const identityKeychainCommandFor = (
369
  platform: NodeJS.Platform,
370
  operation: "get" | "put" | "delete",
371
  account: string,
372
  key?: string,
373
): KeychainCommand | undefined => {
374
  if (platform === "darwin") {
375
    if (operation === "get") {
376
      return {
377
        command: "security",
378
        args: ["find-generic-password", "-a", account, "-s", IDENTITY_KEYCHAIN_SERVICE, "-w"],
379
      };
380
    }
381
    if (operation === "put" && key !== undefined) {
382
      // `security` reads the value from argv, so the wrapping key is briefly
383
      // visible to `ps`. The seed phrase never is: it goes to the file sealed,
384
      // and the key alone opens nothing without that file.
385
      return {
386
        command: "security",
387
        args: [
388
          "add-generic-password",
389
          "-U",
390
          "-a",
391
          account,
392
          "-s",
393
          IDENTITY_KEYCHAIN_SERVICE,
394
          "-w",
395
          key,
396
        ],
397
      };
398
    }
399
    if (operation === "delete") {
400
      return {
401
        command: "security",
402
        args: ["delete-generic-password", "-a", account, "-s", IDENTITY_KEYCHAIN_SERVICE],
403
      };
404
    }
405
    return undefined;
406
  }
407
  if (platform === "linux") {
408
    if (operation === "get") {
409
      return {
410
        command: "secret-tool",
411
        args: ["lookup", "service", IDENTITY_KEYCHAIN_SERVICE, "account", account],
412
      };
413
    }
414
    if (operation === "put" && key !== undefined) {
415
      return {
416
        command: "secret-tool",
417
        args: [
418
          "store",
419
          "--label=OpenAgents identity",
420
          "service",
421
          IDENTITY_KEYCHAIN_SERVICE,
422
          "account",
423
          account,
424
        ],
425
        input: key,
426
      };
427
    }
428
    if (operation === "delete") {
429
      return {
430
        command: "secret-tool",
431
        args: ["clear", "service", IDENTITY_KEYCHAIN_SERVICE, "account", account],
432
      };
433
    }
434
  }
435
  return undefined;
436
};
437
438
const HEX_KEY = /^[0-9a-f]{64}$/;
439
164 440
/**
165
 * Read the stored mnemonic. This is the only function that returns secret
166
 * material, and every caller of it either derives from it or hands it to the
167
 * reader who asked for a backup. Returns `undefined` when no seed is stored.
441
 * The OS keychain: `security` on macOS, `secret-tool` on Linux.
442
 *
443
 * The record is keyed by the identity directory, exactly as the credential store
444
 * keys tokens by origin, so a second identity directory gets a second key and a
445
 * test with a temporary directory can never reach the developer's own.
168 446
 */
169
export const readSeedPhrase = (): string | undefined => {
447
export const osKeychainKeyStore = (account: string): SeedKeyStore => {
448
  const run = (operation: "get" | "put" | "delete", key?: string) => {
449
    const command = identityKeychainCommandFor(process.platform, operation, account, key);
450
    if (command === undefined) return undefined;
451
    const result = spawnSync(command.command, [...command.args], {
452
      input: command.input,
453
      encoding: "utf8",
454
      stdio: ["pipe", "pipe", "ignore"],
455
    });
456
    // A `security` or `secret-tool` that will not start is not an empty store:
457
    // this platform has no keychain, and that is a different answer.
458
    if (result.error !== undefined) return undefined;
459
    return result;
460
  };
461
462
  return {
463
    available: () => run("get") !== undefined,
464
    get: () => {
465
      const result = run("get");
466
      if (result === undefined) return undefined;
467
      if (result.status !== 0) return undefined;
468
      const value = (result.stdout ?? "").trim();
469
      if (value.length === 0) return undefined;
470
      if (!HEX_KEY.test(value)) {
471
        // Never regenerate here. A record that is not a wrapping key means
472
        // something else wrote it, and overwriting it would make the sealed seed
473
        // permanently unopenable.
474
        throw new Error(
475
          `The record under service ${IDENTITY_KEYCHAIN_SERVICE} is not an identity wrapping key.`,
476
        );
477
      }
478
      return Uint8Array.from(Buffer.from(value, "hex"));
479
    },
480
    put: (key) => {
481
      const encoded = Buffer.from(key).toString("hex");
482
      const result = run("put", encoded);
483
      if (result === undefined || result.status !== 0) {
484
        throw new Error("The OS keychain refused to store the identity wrapping key.");
485
      }
486
      const readBack = run("get");
487
      if (readBack === undefined || (readBack.stdout ?? "").trim() !== encoded) {
488
        throw new Error("The OS keychain did not return the key that was just written.");
489
      }
490
    },
491
    delete: () => {
492
      run("delete");
493
    },
494
  };
495
};
496
497
/**
498
 * A machine with no keychain: CI, a container, an unattended agent host. This
499
 * selects the plaintext store and the warning that goes with it.
500
 */
501
export const noKeyStore: SeedKeyStore = {
502
  available: () => false,
503
  get: () => undefined,
504
  put: () => {
505
    throw new Error("This machine has no OS keychain, so there is nowhere to hold a key.");
506
  },
507
  delete: () => {},
508
};
509
510
/**
511
 * A keychain that lives for the length of one test, so the seal, open, and
512
 * migration paths are exercised for real without writing to the developer's own
513
 * keychain or depending on one existing.
514
 */
515
export const inMemoryKeyStore = (): SeedKeyStore => {
516
  let held: Uint8Array | undefined;
517
  return {
518
    available: () => true,
519
    get: () => held,
520
    put: (key) => {
521
      held = Uint8Array.from(key);
522
    },
523
    delete: () => {
524
      held = undefined;
525
    },
526
  };
527
};
528
529
/** True when the environment asks for the plaintext store. */
530
const plaintextRequested = (): boolean => {
531
  const value = (process.env[PLAINTEXT_ENV] ?? "").trim().toLowerCase();
532
  return !(value.length === 0 || value === "0" || value === "false" || value === "no");
533
};
534
535
/**
536
 * The production key store: the OS keychain, unless {@link PLAINTEXT_ENV} says
537
 * otherwise. Computed per call because the identity directory is an environment
538
 * override and may change between calls in a test.
539
 */
540
export const defaultSeedKeyStore = (): SeedKeyStore =>
541
  plaintextRequested() ? noKeyStore : osKeychainKeyStore(seedDirectory());
542
543
// ---------------------------------------------------------------------------
544
// reading and writing the seed
545
// ---------------------------------------------------------------------------
546
547
/** A seed read back off disk, and what was protecting it there. */
548
export interface StoredSeed {
549
  /** The mnemonic. Secret. */
550
  readonly phrase: string;
551
  readonly protection: SeedProtection;
552
}
553
554
/**
555
 * What a write would use on this machine right now: the keychain when one
556
 * answers, the plaintext file when none exists. A keychain that answers with a
557
 * record that is not a wrapping key throws from {@link SeedKeyStore.get} rather
558
 * than being downgraded to plaintext here.
559
 */
560
export const seedProtectionAvailable = (
561
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
562
): SeedProtection => (keyStore.available() ? "os_keychain" : "plaintext_file");
563
564
/**
565
 * What is protecting the seed that is on disk now, without opening it.
566
 * `undefined` when nothing is stored.
567
 */
568
export const seedProtectionOnDisk = (): SeedProtection | undefined => {
170 569
  const path = seedPath();
171 570
  if (!existsSync(path)) return undefined;
172
  const phrase = normalize(readFileSync(path, "utf8"));
173
  return phrase.length === 0 ? undefined : phrase;
571
  const text = readFileSync(path, "utf8");
572
  if (text.trim().length === 0) return undefined;
573
  return looksSealed(text) ? "os_keychain" : "plaintext_file";
174 574
};
175 575
176 576
/**
177
 * Write the mnemonic, `0600` inside a `0700` directory, after validating it.
178
 * The validation is not politeness: an unwritable-back phrase stored here would
179
 * be an identity that cannot be recovered from its own backup.
577
 * Read the stored seed and report what was protecting it. This and
578
 * {@link readSeedPhrase} are the only functions that return secret material.
180 579
 */
181
export const writeSeedPhrase = (phrase: string): string => {
182
  const normalized = normalize(phrase);
183
  if (!validateMnemonic(normalized, wordlist)) {
184
    throw new Error("The seed phrase is not a valid English BIP-39 mnemonic.");
580
export const loadSeed = (
581
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
582
): StoredSeed | undefined => {
583
  const path = seedPath();
584
  if (!existsSync(path)) return undefined;
585
  const text = readFileSync(path, "utf8");
586
  if (text.trim().length === 0) return undefined;
587
  if (!looksSealed(text)) return { phrase: normalize(text), protection: "plaintext_file" };
588
  // Sealed. A keychain that cannot be read is never reported as "no seed": that
589
  // reads as an identity that vanished, and the next command would offer to make
590
  // a new one.
591
  const key = keyStore.get();
592
  if (key === undefined) {
593
    throw new Error(
594
      `The seed at ${path} is encrypted, and the OS keychain holds no key that opens it. ` +
595
        "The key does not travel with the file and is not in any backup of it. " +
596
        "Restore the seed phrase with openagents identity import.",
597
    );
185 598
  }
599
  return { phrase: openEnvelope(text, key, path), protection: "os_keychain" };
600
};
601
602
/**
603
 * Read the stored mnemonic. Every caller either derives from it or hands it to
604
 * the reader who asked for a backup. Returns `undefined` when no seed is stored.
605
 */
606
export const readSeedPhrase = (
607
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
608
): string | undefined => {
609
  const stored = loadSeed(keyStore);
610
  return stored === undefined || stored.phrase.length === 0 ? undefined : stored.phrase;
611
};
612
613
const writeAtomic = (body: string): string => {
186 614
  const directory = seedDirectory();
187 615
  mkdirSync(directory, { recursive: true, mode: 0o700 });
616
  chmodSync(directory, 0o700);
188 617
  const path = seedPath();
189
  writeFileSync(path, `${normalized}\n`, { mode: 0o600 });
618
  const temporary = seedTempPath();
619
  rmSync(temporary, { force: true });
620
  try {
621
    writeFileSync(temporary, body, { mode: 0o600 });
622
    chmodSync(temporary, 0o600);
623
    renameSync(temporary, path);
624
  } catch (cause) {
625
    rmSync(temporary, { force: true });
626
    throw cause;
627
  }
190 628
  chmodSync(path, 0o600);
191 629
  return path;
192 630
};
193 631
194
/** Remove the stored seed. Idempotent, and it deletes nothing else. */
195
export const forgetSeedPhrase = (): boolean => {
632
/**
633
 * Write the mnemonic under the best protection this machine has, `0600` inside a
634
 * `0700` directory, after validating it. The validation is not politeness: an
635
 * unwritable-back phrase stored here would be an identity that cannot be
636
 * recovered from its own backup.
637
 *
638
 * The write is atomic — staged in a sibling file and renamed over the target —
639
 * so the phrase is never in two files at once and a crash mid-write leaves the
640
 * previous seed intact rather than half of the new one.
641
 */
642
export const storeSeedPhrase = (
643
  phrase: string,
644
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
645
): { readonly path: string; readonly protection: SeedProtection } => {
646
  const normalized = normalize(phrase);
647
  if (!validateMnemonic(normalized, wordlist)) {
648
    throw new Error("The seed phrase is not a valid English BIP-39 mnemonic.");
649
  }
650
  const protection = seedProtectionAvailable(keyStore);
651
  if (protection === "plaintext_file") {
652
    return { path: writeAtomic(`${normalized}\n`), protection };
653
  }
654
  let key = keyStore.get();
655
  if (key === undefined) {
656
    // Prove the keychain kept it before anything is sealed under it. Sealing
657
    // first would produce a file no key opens.
658
    const fresh = Uint8Array.from(randomBytes(SEED_KEY_BYTES));
659
    keyStore.put(fresh);
660
    key = fresh;
661
  }
662
  return { path: writeAtomic(`${sealPhrase(normalized, key)}\n`), protection };
663
};
664
665
/** {@link storeSeedPhrase}, for callers that only need the path. */
666
export const writeSeedPhrase = (
667
  phrase: string,
668
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
669
): string => storeSeedPhrase(phrase, keyStore).path;
670
671
/**
672
 * Move a plaintext seed under the OS keychain, and report what is protecting it
673
 * afterwards. `undefined` when nothing is stored.
674
 *
675
 * The rewrite lands on the same path by rename, so there is never a moment with
676
 * the phrase in two files, and the plaintext is gone the instant the sealed
677
 * envelope arrives. On a machine with no keychain this changes nothing and
678
 * reports `plaintext_file`, which is what the caller then has to say out loud.
679
 */
680
export const protectSeed = (
681
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
682
): SeedProtection | undefined => {
683
  const onDisk = seedProtectionOnDisk();
684
  if (onDisk === undefined) return undefined;
685
  if (onDisk === "os_keychain") return "os_keychain";
686
  if (seedProtectionAvailable(keyStore) !== "os_keychain") return "plaintext_file";
687
  const stored = loadSeed(keyStore);
688
  if (stored === undefined) return undefined;
689
  return storeSeedPhrase(stored.phrase, keyStore).protection;
690
};
691
692
/**
693
 * Remove the stored seed, and the wrapping key with it. Idempotent, and it
694
 * deletes nothing else. Leaving the key behind would leave a keychain record for
695
 * an identity that no longer exists.
696
 */
697
export const forgetSeedPhrase = (keyStore: SeedKeyStore = defaultSeedKeyStore()): boolean => {
196 698
  const path = seedPath();
197
  if (!existsSync(path)) return false;
699
  rmSync(seedTempPath(), { force: true });
700
  if (!existsSync(path)) {
701
    keyStore.delete();
702
    return false;
703
  }
198 704
  rmSync(path);
705
  keyStore.delete();
199 706
  return true;
200 707
};
packages/openagents-cli/test/identity-command.test.ts modified +49 -1

@@ -21,7 +21,7 @@ import { environmentLayerFromValues } from "../src/environment.js";

21 21
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
22 22
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
23 23
import { secretInputTestLayer } from "../src/secret-input.js";
24
import { seedPath } from "../src/seed-identity.js";
24
import { PLAINTEXT_ENV, seedPath } from "../src/seed-identity.js";
25 25
import { terminalSessionTestLayer } from "../src/terminal-session.js";
26 26
27 27
const TEST_PHRASE =

@@ -36,6 +36,11 @@ interface Written {

36 36
37 37
const harness = (stdin = TEST_PHRASE) => {
38 38
  process.env["OPENAGENTS_IDENTITY_DIR"] = mkdtempSync(join(tmpdir(), "identity-command-"));
39
  // The headless posture on purpose. It is what CI and an unattended agent host
40
  // actually have, it is the case the warning below exists for, and it keeps a
41
  // developer's own OS keychain out of the test run. The encrypted store is
42
  // covered end to end in `seed-identity.test.ts`.
43
  process.env[PLAINTEXT_ENV] = "1";
39 44
  const written: Array<Written> = [];
40 45
  const layer = Layer.mergeAll(
41 46
    NodeServices.layer,

@@ -72,6 +77,7 @@ const messageOf = (error: unknown): string =>

72 77
73 78
afterEach(() => {
74 79
  delete process.env["OPENAGENTS_IDENTITY_DIR"];
80
  delete process.env[PLAINTEXT_ENV];
75 81
});
76 82
77 83
describe("openagents identity", () => {

@@ -161,4 +167,46 @@ describe("openagents identity", () => {

161 167
    await cli.run(["identity", "show"]);
162 168
    expect(cli.last()?.document.human.join("\n")).toMatch(/Spending rail: not selected/);
163 169
  });
170
171
  /**
172
   * The fallback has to reach a person, not just a code path. A CLI that quietly
173
   * drops back to plaintext is the same shape of defect as a redaction that
174
   * reports success and leaves the secret in place, so every command that shows
175
   * an identity says which store it is on and, for the plaintext one, what that
176
   * store does not protect against.
177
   */
178
  it("says the seed is plaintext, on every surface that shows an identity", async () => {
179
    const cli = harness();
180
181
    await cli.run(["identity", "import"]);
182
    const imported = cli.last()?.document.human.join("\n") ?? "";
183
    expect(imported).toMatch(/Protection: NONE/);
184
    expect(imported).toContain(seedPath());
185
    expect(imported).toMatch(/backup tool/);
186
187
    await cli.run(["identity", "show"]);
188
    expect(cli.last()?.document.human.join("\n")).toMatch(/Protection: NONE/);
189
190
    // The person about to write the phrase down is the one who most needs it.
191
    await cli.run(["identity", "backup"]);
192
    expect(cli.last()?.document.human.join("\n")).toMatch(/Protection: NONE/);
193
194
    // And a machine reader gets it as a field, not only as prose.
195
    await cli.run(["--json", "identity", "show"]);
196
    expect(cli.last()?.document.value).toMatchObject({
197
      seed_protection: "plaintext_file",
198
      seed_encrypted_at_rest: false,
199
      seed_path: seedPath(),
200
    });
201
  });
202
203
  it("says the same thing about a freshly created seed", async () => {
204
    const cli = harness();
205
    await cli.run(["--json", "identity", "create"]);
206
    expect(cli.last()?.document.value).toMatchObject({
207
      created: true,
208
      seed_protection: "plaintext_file",
209
      seed_encrypted_at_rest: false,
210
    });
211
  });
164 212
});
packages/openagents-cli/test/seed-identity.test.ts modified +252 -18

@@ -18,7 +18,14 @@

18 18
 * tooling everywhere. If this file ever disagrees with it, this file is wrong.
19 19
 */
20 20
21
import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
21
import {
22
  chmodSync,
23
  mkdtempSync,
24
  readdirSync,
25
  readFileSync,
26
  statSync,
27
  writeFileSync,
28
} from "node:fs";
22 29
import { tmpdir } from "node:os";
23 30
import { join } from "node:path";
24 31

@@ -26,17 +33,28 @@ import {

26 33
  deriveSovereignIdentityPublic,
27 34
  PUBLIC_TEST_IDENTITY_EMPTY_PASSPHRASE,
28 35
} from "@openagentsinc/sovereign-identity/contract";
29
import { afterEach, describe, expect, it } from "vitest";
36
import { afterEach, beforeEach, describe, expect, it } from "vitest";
30 37
31 38
import {
39
  describeSeedProtection,
32 40
  deriveSeedIdentity,
33 41
  forgetSeedPhrase,
34 42
  generateSeedPhrase,
43
  identityKeychainCommandFor,
44
  IDENTITY_KEYCHAIN_SERVICE,
45
  inMemoryKeyStore,
35 46
  isValidSeedPhrase,
47
  loadSeed,
48
  noKeyStore,
49
  protectSeed,
36 50
  readSeedPhrase,
51
  seedEncryptedAtRest,
37 52
  seedPath,
38 53
  seedPresent,
54
  seedProtectionOnDisk,
55
  storeSeedPhrase,
39 56
  writeSeedPhrase,
57
  type SeedKeyStore,
40 58
} from "../src/seed-identity.js";
41 59
42 60
/**

@@ -107,49 +125,265 @@ describe("seed derivation", () => {

107 125
});
108 126
109 127
describe("seed storage", () => {
128
  /**
129
   * A wrapping key that lives for the length of one test. Nothing here reaches
130
   * the developer's own OS keychain, and nothing depends on the machine running
131
   * the tests having one.
132
   */
133
  let keys: SeedKeyStore;
134
135
  beforeEach(() => {
136
    keys = inMemoryKeyStore();
137
  });
138
110 139
  it("writes the phrase 0600 and reads it back unchanged", () => {
111 140
    isolatedIdentityDirectory();
112 141
    expect(seedPresent()).toBe(false);
113
    const path = writeSeedPhrase(`  ${TEST_PHRASE}  `);
142
    const path = writeSeedPhrase(`  ${TEST_PHRASE}  `, keys);
114 143
    expect(path).toBe(seedPath());
115 144
    expect(seedPresent()).toBe(true);
116 145
    expect(statSync(path).mode & 0o777).toBe(0o600);
117
    expect(readSeedPhrase()).toBe(TEST_PHRASE);
118
    expect(deriveSeedIdentity(readSeedPhrase() ?? "").npub).toBe(FROZEN.npub);
146
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
147
    expect(deriveSeedIdentity(readSeedPhrase(keys) ?? "").npub).toBe(FROZEN.npub);
119 148
  });
120 149
121 150
  it("restores 0600 when the file on disk was left readable", () => {
122 151
    isolatedIdentityDirectory();
123
    const path = writeSeedPhrase(TEST_PHRASE);
152
    const path = writeSeedPhrase(TEST_PHRASE, keys);
124 153
    chmodSync(path, 0o644);
125
    writeSeedPhrase(TEST_PHRASE);
154
    writeSeedPhrase(TEST_PHRASE, keys);
126 155
    expect(statSync(path).mode & 0o777).toBe(0o600);
127 156
  });
128 157
129 158
  it("writes nothing when the phrase is not a valid mnemonic", () => {
130 159
    isolatedIdentityDirectory();
131
    expect(() => writeSeedPhrase("not a seed phrase at all")).toThrow(/valid English BIP-39/);
160
    expect(() => writeSeedPhrase("not a seed phrase at all", keys)).toThrow(/valid English BIP-39/);
132 161
    expect(seedPresent()).toBe(false);
133 162
  });
134 163
135 164
  it("reports no seed for an absent or empty file, and forgets idempotently", () => {
136 165
    const directory = isolatedIdentityDirectory();
137
    expect(readSeedPhrase()).toBeUndefined();
138
    expect(forgetSeedPhrase()).toBe(false);
166
    expect(readSeedPhrase(keys)).toBeUndefined();
167
    expect(forgetSeedPhrase(keys)).toBe(false);
139 168
    writeFileSync(join(directory, "seed"), "   \n", { mode: 0o600 });
140
    expect(readSeedPhrase()).toBeUndefined();
141
    writeSeedPhrase(TEST_PHRASE);
142
    expect(forgetSeedPhrase()).toBe(true);
169
    expect(readSeedPhrase(keys)).toBeUndefined();
170
    writeSeedPhrase(TEST_PHRASE, keys);
171
    expect(keys.get()).toBeDefined();
172
    expect(forgetSeedPhrase(keys)).toBe(true);
143 173
    expect(seedPresent()).toBe(false);
144
    expect(forgetSeedPhrase()).toBe(false);
174
    // Forget takes the wrapping key with it. A key left behind is a keychain
175
    // record for an identity that no longer exists.
176
    expect(keys.get()).toBeUndefined();
177
    expect(forgetSeedPhrase(keys)).toBe(false);
145 178
  });
146 179
147
  it("keeps the seed out of everything except the seed file", () => {
148
    const directory = isolatedIdentityDirectory();
149
    writeSeedPhrase(TEST_PHRASE);
180
  it("keeps the seed out of the derived identity", () => {
181
    isolatedIdentityDirectory();
182
    writeSeedPhrase(TEST_PHRASE, keys);
150 183
    const identity = deriveSeedIdentity(TEST_PHRASE);
151
    expect(readFileSync(join(directory, "seed"), "utf8")).toContain(TEST_PHRASE);
152 184
    expect(JSON.stringify(identity)).not.toContain("abandon");
153 185
    expect(JSON.stringify(identity)).not.toContain("nsec");
154 186
  });
155 187
});
188
189
describe("seed protection at rest", () => {
190
  /**
191
   * The claim under test is not "encryption was called". It is that the bytes a
192
   * backup tool, a sync client, or an agent reading `$HOME` would carry away are
193
   * not the phrase, and not any word of it.
194
   */
195
  it("leaves no word of the phrase in the seed file", () => {
196
    const directory = isolatedIdentityDirectory();
197
    const keys = inMemoryKeyStore();
198
    const { path, protection } = storeSeedPhrase(TEST_PHRASE, keys);
199
200
    const onDisk = readFileSync(path, "utf8");
201
    expect(onDisk).not.toContain(TEST_PHRASE);
202
    expect(onDisk).not.toContain("abandon");
203
    expect(onDisk).not.toContain("about");
204
205
    // And it is the sealed envelope, not some other encoding of the same words:
206
    // a base64 or hex of the phrase would pass the checks above.
207
    expect(onDisk).toContain("chacha20-poly1305");
208
    expect(onDisk).toContain("openagents.cli_identity_seed.v1");
209
    expect(protection).toBe("os_keychain");
210
    expect(seedEncryptedAtRest(protection)).toBe(true);
211
    expect(seedProtectionOnDisk()).toBe("os_keychain");
212
213
    // The wrapping key is not in the identity directory. If it were, the file
214
    // and the key would travel together and the encryption would be theatre.
215
    expect(readdirSync(directory)).toEqual(["seed"]);
216
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
217
  });
218
219
  it("uses a fresh nonce for every seal", () => {
220
    isolatedIdentityDirectory();
221
    const keys = inMemoryKeyStore();
222
    const { path } = storeSeedPhrase(TEST_PHRASE, keys);
223
    const first = readFileSync(path, "utf8");
224
    storeSeedPhrase(TEST_PHRASE, keys);
225
    const second = readFileSync(path, "utf8");
226
227
    expect(second).not.toBe(first);
228
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
229
  });
230
231
  /**
232
   * A sealed seed whose key is gone must say so. Reporting "no seed" would read
233
   * as an identity that vanished, and the next command would offer a new one.
234
   */
235
  it("treats a sealed seed without its key as an error, not an absence", () => {
236
    isolatedIdentityDirectory();
237
    const keys = inMemoryKeyStore();
238
    storeSeedPhrase(TEST_PHRASE, keys);
239
    keys.delete();
240
241
    expect(seedPresent()).toBe(true);
242
    expect(() => readSeedPhrase(keys)).toThrow(/encrypted/);
243
    try {
244
      readSeedPhrase(keys);
245
    } catch (cause) {
246
      expect(String(cause)).not.toContain("abandon");
247
    }
248
  });
249
250
  it("refuses a key that does not open the envelope rather than returning rubbish", () => {
251
    isolatedIdentityDirectory();
252
    const keys = inMemoryKeyStore();
253
    storeSeedPhrase(TEST_PHRASE, keys);
254
    keys.put(new Uint8Array(32).fill(7));
255
256
    expect(() => readSeedPhrase(keys)).toThrow(/does not open it/);
257
  });
258
259
  /**
260
   * The headless case, stated rather than assumed: with no keychain the phrase
261
   * is on disk as text, and the module says exactly that so the CLI can print
262
   * it.
263
   */
264
  it("says the seed is plaintext when there is no keychain", () => {
265
    isolatedIdentityDirectory();
266
    const { path, protection } = storeSeedPhrase(TEST_PHRASE, noKeyStore);
267
268
    expect(protection).toBe("plaintext_file");
269
    expect(seedEncryptedAtRest(protection)).toBe(false);
270
    expect(readFileSync(path, "utf8")).toContain(TEST_PHRASE);
271
    expect(readSeedPhrase(noKeyStore)).toBe(TEST_PHRASE);
272
    expect(seedProtectionOnDisk()).toBe("plaintext_file");
273
274
    // The sentence a person sees must name the file and say what is not covered.
275
    const described = describeSeedProtection(protection, path);
276
    expect(described).toContain(path);
277
    expect(described).toContain("readable text");
278
    expect(described).toContain("backup tool");
279
    expect(described).not.toContain(TEST_PHRASE);
280
  });
281
282
  it("names the keychain service in the sentence for the encrypted store", () => {
283
    const described = describeSeedProtection("os_keychain", "/tmp/seed");
284
    expect(described).toContain(IDENTITY_KEYCHAIN_SERVICE);
285
    expect(described).toContain("chacha20-poly1305");
286
  });
287
288
  /**
289
   * The migration. Start from a seed file written by the CLI that could not
290
   * encrypt one, and prove both halves: the identity is unchanged, and the
291
   * plaintext is gone.
292
   */
293
  it("migrates an existing plaintext seed and leaves no plaintext behind", () => {
294
    const directory = isolatedIdentityDirectory();
295
    const path = join(directory, "seed");
296
297
    // Exactly what the previous CLI wrote: the phrase, one line, mode 0600.
298
    writeFileSync(path, `${TEST_PHRASE}\n`, { mode: 0o600 });
299
    const before = deriveSeedIdentity(TEST_PHRASE);
300
    expect(seedProtectionOnDisk()).toBe("plaintext_file");
301
302
    const keys = inMemoryKeyStore();
303
    expect(protectSeed(keys)).toBe("os_keychain");
304
305
    // The identity did not move.
306
    const after = deriveSeedIdentity(readSeedPhrase(keys) ?? "");
307
    expect(after).toEqual(before);
308
    expect(after.npub).toBe(FROZEN.npub);
309
310
    // The plaintext is gone, from that file and from every other file the
311
    // migration could have left in the directory.
312
    expect(readFileSync(path, "utf8")).not.toContain("abandon");
313
    for (const entry of readdirSync(directory)) {
314
      expect(readFileSync(join(directory, entry), "utf8")).not.toContain("abandon");
315
    }
316
    expect(statSync(path).mode & 0o777).toBe(0o600);
317
318
    // Migrating twice is not a second identity, and not a second file.
319
    expect(protectSeed(keys)).toBe("os_keychain");
320
    expect(readdirSync(directory)).toEqual(["seed"]);
321
    expect(loadSeed(keys)?.phrase).toBe(TEST_PHRASE);
322
  });
323
324
  it("reports plaintext rather than faking a migration on a headless machine", () => {
325
    const directory = isolatedIdentityDirectory();
326
    const path = join(directory, "seed");
327
    writeFileSync(path, `${TEST_PHRASE}\n`, { mode: 0o600 });
328
329
    expect(protectSeed(noKeyStore)).toBe("plaintext_file");
330
    expect(readFileSync(path, "utf8")).toContain(TEST_PHRASE);
331
    expect(readSeedPhrase(noKeyStore)).toBe(TEST_PHRASE);
332
  });
333
334
  /**
335
   * A seed sealed by one CLI opens in the other. Both write the same envelope
336
   * under the same key at the same path, so this asserts the format rather than
337
   * the language. The Rust side pins the same property in
338
   * `crates/openagents-cli/tests/identity_test.rs`.
339
   */
340
  it("opens an envelope from a second store holding the same key", () => {
341
    isolatedIdentityDirectory();
342
    const key = new Uint8Array(32).fill(42);
343
344
    const writer = inMemoryKeyStore();
345
    writer.put(key);
346
    storeSeedPhrase(TEST_PHRASE, writer);
347
348
    const reader = inMemoryKeyStore();
349
    reader.put(key);
350
    expect(readSeedPhrase(reader)).toBe(TEST_PHRASE);
351
    expect(deriveSeedIdentity(readSeedPhrase(reader) ?? "").npub).toBe(FROZEN.npub);
352
  });
353
354
  /**
355
   * The keychain the two CLIs address. The service name, the account key, and
356
   * the operations must match `crates/openagents-cli/src/identity.rs`, or one
357
   * CLI mints a second key and orphans the other's seed.
358
   */
359
  it("addresses the keychain under a service of its own, keyed by identity directory", () => {
360
    const macos = identityKeychainCommandFor("darwin", "get", "/home/a/.openagents/identity");
361
    expect(macos).toEqual({
362
      command: "security",
363
      args: [
364
        "find-generic-password",
365
        "-a",
366
        "/home/a/.openagents/identity",
367
        "-s",
368
        "openagents-cli-identity",
369
        "-w",
370
      ],
371
    });
372
373
    const linux = identityKeychainCommandFor(
374
      "linux",
375
      "put",
376
      "/home/a/.openagents/identity",
377
      "ab".repeat(32),
378
    );
379
    expect(linux?.command).toBe("secret-tool");
380
    expect(linux?.args).toContain("openagents-cli-identity");
381
    // Linux takes the key on stdin; the account tokens keep their own service.
382
    expect(linux?.input).toBe("ab".repeat(32));
383
    expect(linux?.args).not.toContain("openagents-cli");
384
    expect(linux?.args).not.toContain("openagents-cli-computer");
385
386
    // No keychain on Windows, which is what selects the plaintext store there.
387
    expect(identityKeychainCommandFor("win32", "get", "C:/identity")).toBeUndefined();
388
  });
389
});

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