eca5af92 Andy McCall 1 day ago ↶
1
2
3
4
5
6
use super :: { identity, ui, Ctx} ;
7
use anyhow:: { bail, Context, Result } ;
8
use oxy_core:: identity:: Identity;
9
use serde_json:: Value as Json;
10
use spectra_core:: daemon:: WireEvent;
11
use spectra_core:: keys:: KeyPair;
12
use spectra_core:: principals:: { identity_payload, signed_event, DELEGATION_SCOPE } ;
13
use spectra_mcp:: client:: { host_port, Client, Endpoint} ;
14
use spectra_mcp:: tls:: Pinned;
15
use std:: path:: { Path, PathBuf} ;
16
17
18
# [ derive ( Clone, Debug ) ]
19
pub struct ClientCfg {
20
pub server : String,
21
pub instance : String,
22
pub tls_spki : [u8 ; 32],
23
}
24
25
impl ClientCfg {
26
pub fn endpoint ( & self ) -> Result< Endpoint> {
27
Endpoint:: tls( & self .server, self .tls_spki)
28
}
29
}
30
31
32
33
pub fn path ( ) -> PathBuf {
34
if let Some ( p) = std:: env:: var_os( " SPECTRA_SERVER" ) .filter ( | p | ! p.is_empty ( ) ) {
35
return PathBuf:: from( p) ;
36
}
37
config_dir ( ) .join ( " client.toml" )
38
}
39
40
fn config_dir ( ) -> PathBuf {
41
let base = std:: env:: var_os( " XDG_CONFIG_HOME" )
42
.filter ( | p | ! p.is_empty ( ) )
43
.map ( PathBuf:: from)
44
.or_else ( | | std:: env:: var_os( " HOME" ) .map ( | h | PathBuf:: from( h) .join ( " .config" ) ) )
45
.unwrap_or_else ( | | PathBuf:: from( " ." ) ) ;
46
base.join ( " spectra" )
47
}
48
49
50
pub fn load ( ) -> Result< Option< ClientCfg> > {
51
let p = path ( ) ;
52
let text = match std:: fs:: read_to_string( & p) {
53
Ok ( t) => t,
54
Err ( e) if e.kind ( ) = = std:: io:: ErrorKind:: NotFound => return Ok ( None ) ,
55
Err ( e) => return Err ( e) .with_context ( | | format! ( " reading {} " , p.display ( ) ) ) ,
56
} ;
57
let t: toml:: Table = text.parse ( ) .with_context ( | | format! ( " reading {} " , p.display ( ) ) ) ? ;
58
let get = | k : & str | t.get ( k) .and_then ( | v | v.as_str ( ) ) .map ( String :: from) .with_context ( | | format! ( " {} has no {k} " , p.display ( ) ) ) ;
59
let spki = hex:: decode( get ( " tls_spki" ) ? ) .ok ( ) .and_then ( | b | b.try_into ( ) .ok ( ) ) .with_context ( | | format! ( " {} : tls_spki" , p.display ( ) ) ) ? ;
60
Ok ( Some ( ClientCfg { server: get ( " server" ) ? , instance: get ( " instance" ) ? , tls_spki: spki } ) )
61
}
62
63
fn save ( cfg : & ClientCfg) -> Result< PathBuf> {
64
let p = path ( ) ;
65
if let Some ( dir) = p.parent ( ) {
66
std:: fs:: create_dir_all( dir) ? ;
67
}
68
std:: fs:: write(
69
& p,
70
format! (
71
" # Spectra on another machine (spec §8.6), written by `spectra connect`.\n \n server = \" {} \" \n instance = \" {} \" # the instance key, delegated by the owner\n tls_spki = \" {} \" # the certificate the instance key vouches for\n " ,
72
cfg.server,
73
cfg.instance,
74
hex:: encode( cfg.tls_spki)
75
) ,
76
) ? ;
77
Ok ( p)
78
}
79
80
81
fn own ( home : & Path, key : & str ) -> Option< KeyPair> {
82
let keys = home.join ( " keys" ) ;
83
KeyPair:: load( & keys.join ( format! ( " {key} .secret" ) ) , & keys.join ( format! ( " {key} .identity" ) ) ) .ok ( )
84
}
85
86
pub async fn connect ( _ctx : & Ctx, url : & str , owner_home : Option< PathBuf> ) -> Result< i32 > {
87
let server = format! ( " https://{} " , host_port ( url) ? ) ;
88
let home = owner_home.unwrap_or_else ( oxy_core:: repo:: default_home) ;
89
let owner = KeyPair:: load_home( & home)
90
.with_context ( | | format! ( " the owner connects with their own identity: none usable in {} (or --owner-home)" , home.display ( ) ) ) ? ;
91
92
93
let probe = Pinned:: new( None ) ;
94
let anon = Client:: at( Endpoint:: Tls { addr: host_port ( url) ? , config: probe.client_config ( ) ? } , None ) ;
95
let r = anon.send ( " GET" , " /api/v1/instance" , & [ ] , vec! [ ] ) .await.with_context ( | | format! ( " reaching {server} " ) ) ? ;
96
if r.status ! = 200 {
97
bail! ( " {server} answered {} to /api/v1/instance: is it a Spectra?" , r.status) ;
98
}
99
let answer = r.json ( ) ? ;
100
let ( _ , seen) = probe.seen ( ) .context ( " the server showed no certificate" ) ? ;
101
let checked = spectra_mcp:: instance:: check( & answer, & seen) ? ;
102
let instance_key = checked.instance.hash ( ) ;
103
104
105
let recorded = checked.owner.clone ( ) ;
106
let named = answer.get ( " named_owner" ) .and_then ( Json:: as_str) .map ( String :: from) ;
107
match ( & recorded, & named) {
108
( Some ( k) , _ ) if * k ! = owner.key.to_hex ( ) & & own ( & home, k) .is_none ( ) => {
109
bail! ( " {server} belongs to someone else (owner {})" , & k[ .. 12. min ( k.len ( ) ) ] ) ;
110
}
111
( None , Some ( k) ) if own ( & home, k) .is_none ( ) => {
112
bail! ( " {server} was set up for {}, which is not an identity in {}" , & k[ .. 12. min ( k.len ( ) ) ] , home.display ( ) ) ;
113
}
114
( None , None ) => bail! ( " {server} has no owner and names none" ) ,
115
_ => { }
116
}
117
118
119
if ! owner.identity.delegates_to ( & instance_key, DELEGATION_SCOPE , spectra_core:: ids:: now_ms( ) ) {
120
let dir = config_dir ( ) ;
121
std:: fs:: create_dir_all( & dir) ? ;
122
let file = dir.join ( format! ( " {} -instance.identity" , safe ( & checked.name) ) ) ;
123
std:: fs:: write( & file, checked.instance.encode ( ) ) ? ;
124
println! ( " {} {} {} " , ui:: caution( ) , ui:: subject( & server) , ui:: dim( " is not yet yours to trust" ) ) ;
125
println! ( " {} " , ui:: kv( " instance" , format! ( " {} {} {} " , checked.name, ui:: sep( ) , ui:: hash( instance_key.short ( 12 ) ) ) ) ) ;
126
println! ( " {} {} " , ui:: note( ) , ui:: dim( " your identity does not delegate to its instance key; if this is your server, run:" ) ) ;
127
println! ( " {} " , ui:: id( format! ( " oxy identity delegate add {} --scope changes" , file.display ( ) ) ) ) ;
128
println! ( " {} {} " , ui:: note( ) , ui:: dim( " then run spectra connect again" ) ) ;
129
return Ok ( 1 ) ;
130
}
131
132
let cfg = ClientCfg { server: server.clone ( ) , instance: instance_key.to_hex ( ) , tls_spki: seen } ;
133
let saved = save ( & cfg) ? ;
134
let pinned = cfg.endpoint ( ) ? ;
135
136
137
let mut claimed = false ;
138
match ( & recorded, & named) {
139
( None , Some ( k) ) => {
140
let named_pair = own ( & home, k) .expect ( " checked above" ) ;
141
let mut events =
142
vec! [ WireEvent:: from_new( & signed_event ( & named_pair, " human" , " identity.owner" , identity_payload ( & named_pair.identity) ) ) ] ;
143
if let Some ( e) = identity:: rotation( & named_pair.identity, & home) ? {
144
events.push ( WireEvent:: from_new( & e) ) ;
145
}
146
let r = Client:: at( pinned.clone ( ) , None ) .send ( " POST" , " /api/v1/claim" , & [ ] , serde_json:: to_vec( & events) ? ) .await? ;
147
if r.status ! = 200 {
148
bail! ( " {server} refused the claim: {}" , problem ( & r.body) ) ;
149
}
150
claimed = true ;
151
}
152
( Some ( k) , _ ) if * k ! = owner.key.to_hex ( ) => {
153
let rec_pair = own ( & home, k) .expect ( " checked above" ) ;
154
let rec_id: Identity = rec_pair.identity.clone ( ) ;
155
if let Some ( e) = identity:: rotation( & rec_id, & home) ? {
156
let body = serde_json:: to_vec( & vec! [ WireEvent:: from_new( & e) ] ) ? ;
157
let r = Client:: at( pinned.clone ( ) , Some ( rec_pair) ) .send ( " POST" , " /api/v1/ledger/events" , & [ ] , body) .await? ;
158
if r.status ! = 200 {
159
bail! ( " {server} refused the owner's current identity: {}" , problem ( & r.body) ) ;
160
}
161
}
162
}
163
_ => { }
164
}
165
166
println! ( " {} {} {} " , ui:: done( if claimed { " Claimed" } else { " Connected" } ) , ui:: subject( & checked.name) , ui:: path( & server) ) ;
167
println! ( " {} " , ui:: kv( " owner" , format! ( " {} {} {} " , ui:: author( & owner.identity.name) , ui:: sep( ) , ui:: hash( owner.key.short ( 12 ) ) ) ) ) ;
168
println! ( " {} " , ui:: kv( " instance" , format! ( " {} {} " , ui:: hash( instance_key.short ( 12 ) ) , ui:: dim( " (delegated by you)" ) ) ) ) ;
169
println! (
170
" {} " ,
171
ui:: kv( " certificate" , format! ( " {} {} " , ui:: hash( hex:: encode( & seen[ .. 8 ] ) ) , ui:: dim( " (pinned; vouched for by the instance key)" ) ) )
172
) ;
173
println! ( " {} " , ui:: kv( " config" , ui:: path( saved.display ( ) ) ) ) ;
174
Ok ( 0 )
175
}
176
177
fn safe ( name : & str ) -> String {
178
let s: String = name.chars ( ) .map ( | c | if c.is_ascii_alphanumeric ( ) | | c = = ' -' { c } else { ' _' } ) .collect ( ) ;
179
if s.is_empty ( ) {
180
" spectra" .into ( )
181
} else {
182
s
183
}
184
}
185
186
fn problem ( body : & [u8 ]) -> String {
187
serde_json:: from_slice:: < Json> ( body)
188
.ok ( )
189
.and_then ( | v | v.get ( " detail" ) .or_else ( | | v.get ( " title" ) ) .and_then ( Json:: as_str) .map ( String :: from) )
190
.unwrap_or_else ( | | String :: from_utf8_lossy( body) .into_owned ( ) )
191
}