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