Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.

Commit 67f7e6e

Browse files
committed
Add Brain identity authority email proof
1 parent 792fbb3 commit 67f7e6e

7 files changed

Lines changed: 432 additions & 9 deletions

File tree

‎Cargo.lock‎

Lines changed: 9 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ jq -r .secretKey "$FBRAIN_CONFIG_DIR/auth.json" | fbrain auth import
9292
rm "$FBRAIN_CONFIG_DIR/auth.json"
9393
```
9494

95+
### Email Proof
96+
97+
When `FINITE_IDENTITY_AUTHORITY` points at a finite-identity deployment,
98+
`fbrain auth login EMAIL` requests an email challenge and
99+
`fbrain auth redeem EMAIL TOKEN` proves that email with the shared Finite
100+
identity. For `@finite.vip` emails, redemption binds the email to the local
101+
User Key in finite-identity and returns the NIP-05 identifier for that email.
102+
103+
External email redemption is recorded as email-only identity proof in
104+
finite-identity, but FiniteBrain folder sharing still requires an npub target
105+
for encrypted Folder Key Grants. Email-address folder grants are intentionally
106+
left for a future crypto-aware slice.
107+
95108
## Open A Vault Working Tree
96109

97110
Use an explicit config directory in agent runtimes so fbrain state does not

‎crates/finite-brain-cli/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ base64 = "0.22.1"
1818
finite-brain-core = { path = "../finite-brain-core" }
1919
# Shared Finite identity per the Finite Identity Contract v1: one key per
2020
# user, whichever Finite tool runs first mints it. Pinned to the contract rev.
21-
finite-identity = { git = "https://github.com/finitecomputer/finite-identity", rev = "54a6936b5d7a0e8dc79018a30d9c794b10d25307" }
21+
finite-identity = { git = "https://github.com/finitecomputer/finite-identity", rev = "8d0c60316ed4b2bcf7d54e79c287eb962007b4f9" }
2222
finite-nostr = { path = "../finite-nostr" }
2323
nostr = { version = "0.45.0-alpha.2", features = ["nip44", "os-rng"] }
2424
serde = { version = "1.0.228", features = ["derive"] }

‎crates/finite-brain-cli/src/environment.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ pub struct CliEnvironment {
77
pub cwd: PathBuf,
88
pub config_dir: PathBuf,
99
pub now: Option<String>,
10+
/// Optional finite-identity Authority URL for email proof and native
11+
/// finite.vip binding flows.
12+
pub identity_authority_url: Option<String>,
1013
/// Explicit Finite home for the shared identity, used by tests and
1114
/// embedders. `None` resolves per the Finite Identity Contract v1:
1215
/// `$FINITE_HOME/identity/` when `FINITE_HOME` is set, otherwise
@@ -26,10 +29,15 @@ impl CliEnvironment {
2629
})
2730
.unwrap_or_else(|| cwd.join(".fbrain"));
2831
let now = env::var("FBRAIN_NOW").ok();
32+
let identity_authority_url = env::var("FINITE_IDENTITY_AUTHORITY")
33+
.ok()
34+
.map(|value| value.trim().trim_end_matches('/').to_owned())
35+
.filter(|value| !value.is_empty());
2936
Self {
3037
cwd,
3138
config_dir,
3239
now,
40+
identity_authority_url,
3341
finite_home: None,
3442
}
3543
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! finite-identity Authority HTTP helpers used by fbrain auth email flows.
2+
3+
use serde::{Deserialize, Serialize};
4+
5+
use crate::{CliEnvironment, CliError, load_or_generate_identity, unix_timestamp};
6+
7+
#[derive(Debug, Clone)]
8+
pub(crate) struct IdentityAuthorityClient {
9+
base_url: String,
10+
client: finite_identity::client::IdentityClient,
11+
}
12+
13+
impl IdentityAuthorityClient {
14+
pub(crate) fn from_environment(env: &CliEnvironment) -> Result<Self, CliError> {
15+
let base_url = env
16+
.identity_authority_url
17+
.as_deref()
18+
.map(str::trim)
19+
.filter(|value| !value.is_empty())
20+
.ok_or_else(|| {
21+
CliError::Unsupported(
22+
"email auth requires FINITE_IDENTITY_AUTHORITY to point at finite-identity"
23+
.to_owned(),
24+
)
25+
})?
26+
.trim_end_matches('/')
27+
.to_owned();
28+
Ok(Self {
29+
client: finite_identity::client::IdentityClient::new(&base_url),
30+
base_url,
31+
})
32+
}
33+
34+
pub(crate) fn request_email_challenge(
35+
&self,
36+
email: &str,
37+
) -> Result<EmailChallengeReport, CliError> {
38+
let body = self
39+
.client
40+
.email_challenge_body(email)
41+
.map_err(|error| CliError::Identity(error.to_string()))?;
42+
let body = serde_json::to_vec(&body)?;
43+
let url = format!("{}/api/v1/email-challenges", self.base_url);
44+
let response = ureq::post(&url)
45+
.set("Content-Type", "application/json")
46+
.send_bytes(&body);
47+
identity_response_json(response, "POST", "/api/v1/email-challenges")
48+
}
49+
50+
pub(crate) fn redeem_email(
51+
&self,
52+
env: &CliEnvironment,
53+
email: &str,
54+
token: &str,
55+
) -> Result<EmailRedeemReport, CliError> {
56+
let identity = load_or_generate_identity(env)?;
57+
let key = finite_identity::client::LocalIdentityKey::from_identity(&identity);
58+
if is_finite_vip_email(email) {
59+
let request = self
60+
.client
61+
.vip_email_binding_redeem(&key, email, token, unix_timestamp())
62+
.map_err(|error| CliError::Identity(error.to_string()))?;
63+
let response: VipEmailRedeemResponse = self.send_signed_json(request)?;
64+
Ok(EmailRedeemReport {
65+
email: response.email,
66+
pubkey: response.pubkey,
67+
principal_kind: "native".to_owned(),
68+
nip05: Some(response.nip05),
69+
limitation: None,
70+
})
71+
} else {
72+
let request = self
73+
.client
74+
.email_only_redeem(&key, email, token, unix_timestamp())
75+
.map_err(|error| CliError::Identity(error.to_string()))?;
76+
let response: EmailOnlyRedeemResponse = self.send_signed_json(request)?;
77+
Ok(EmailRedeemReport {
78+
email: response.email,
79+
pubkey: response.pubkey,
80+
principal_kind: "email_only".to_owned(),
81+
nip05: None,
82+
limitation: Some(
83+
"FiniteBrain folder access still requires an npub recipient for encrypted Folder Key Grants; email-address grants are future work."
84+
.to_owned(),
85+
),
86+
})
87+
}
88+
}
89+
90+
fn send_signed_json<T: serde::de::DeserializeOwned>(
91+
&self,
92+
signed: finite_identity::client::SignedJsonRequest,
93+
) -> Result<T, CliError> {
94+
let url = format!("{}{}", self.base_url, signed.path);
95+
let response = ureq::request(&signed.method, &url)
96+
.set("Content-Type", "application/json")
97+
.set("Authorization", &signed.authorization)
98+
.send_bytes(&signed.body);
99+
identity_response_json(response, &signed.method, &signed.path)
100+
}
101+
}
102+
103+
#[derive(Debug, Clone, Deserialize, Serialize)]
104+
#[serde(rename_all = "camelCase")]
105+
pub(crate) struct EmailChallengeReport {
106+
pub(crate) email: String,
107+
}
108+
109+
#[derive(Debug, Clone, Serialize)]
110+
#[serde(rename_all = "camelCase")]
111+
pub(crate) struct EmailRedeemReport {
112+
pub(crate) email: String,
113+
pub(crate) pubkey: String,
114+
pub(crate) principal_kind: String,
115+
pub(crate) nip05: Option<String>,
116+
pub(crate) limitation: Option<String>,
117+
}
118+
119+
#[derive(Debug, Deserialize)]
120+
struct VipEmailRedeemResponse {
121+
email: String,
122+
pubkey: String,
123+
nip05: String,
124+
}
125+
126+
#[derive(Debug, Deserialize)]
127+
struct EmailOnlyRedeemResponse {
128+
email: String,
129+
pubkey: String,
130+
}
131+
132+
fn identity_response_json<T: serde::de::DeserializeOwned>(
133+
response: Result<ureq::Response, ureq::Error>,
134+
method: &str,
135+
path: &str,
136+
) -> Result<T, CliError> {
137+
match response {
138+
Ok(response) => {
139+
let body = response.into_string().map_err(|error| {
140+
CliError::Http(format!(
141+
"could not read Identity Authority response for {method} {path}: {error}"
142+
))
143+
})?;
144+
serde_json::from_str::<T>(&body).map_err(|error| {
145+
CliError::Http(format!(
146+
"invalid Identity Authority response for {method} {path}: {error}"
147+
))
148+
})
149+
}
150+
Err(ureq::Error::Status(status, response)) => {
151+
let code = response
152+
.into_string()
153+
.ok()
154+
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
155+
.and_then(|body: serde_json::Value| {
156+
body.get("error")
157+
.and_then(serde_json::Value::as_str)
158+
.map(ToOwned::to_owned)
159+
})
160+
.unwrap_or_else(|| "no error details".to_owned());
161+
let classified =
162+
finite_identity::client::IdentityClient::classify_authority_error(status, &code)
163+
.map(|error| format!(" ({:?})", error.kind()))
164+
.unwrap_or_default();
165+
Err(CliError::Http(format!(
166+
"Identity Authority {method} {path} returned {status}: {code}{classified}"
167+
)))
168+
}
169+
Err(error) => Err(CliError::Http(format!(
170+
"Identity Authority {method} {path} failed: {error}"
171+
))),
172+
}
173+
}
174+
175+
fn is_finite_vip_email(email: &str) -> bool {
176+
email.trim().to_ascii_lowercase().ends_with("@finite.vip")
177+
}

0 commit comments

Comments
 (0)