Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions scripts/pp-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ async function migration() {
const rep2 = again.data?.report || {};
record('A: a second run writes nothing new', again.ok && rep2.failed === 0 && (await op(A, { op: 'polls' })).data?.length === after.length && ((await op(A, { op: 'drafts' })).data || []).length === drafts15.length,
again.error || JSON.stringify(rep2));
const aLinked = await op(A, { op: 'linked_active' });
record('A: v1.5 links its key to the identity (its own link from the sign-in)', aLinked.ok && aLinked.data === true, aLinked.error || JSON.stringify(aLinked.data));
}

/** A restarted with every rendezvous check failing for ~90 s (debug switch):
Expand Down Expand Up @@ -324,6 +326,15 @@ async function carrier() {
for (const [b, w] of [[A, 'A'], [B, 'B'], [C, 'C'], [D, 'D']]) record(`${w}: ready`, await waitReady(b));
const keyOf = async (b) => (await status(b)).app?.agent_pub_key;
const [aKey, bKey, cKey] = [await keyOf(A), await keyOf(B), await keyOf(C)];
// B signs in on v1.3 as ProofPoll 0.4.x did: its only identity link is
// there. Once its move carries that link, v1.5 links its key to the
// identity, and the app has nothing to ask the Vault for.
const b13 = await op(B, { op: 'v13_link', client_id: CLIENT_ID });
record('B: signed in on v1.3 as 0.4.x did (its only link there)', b13.ok, b13.error);
const bBefore = await op(B, { op: 'linked_active' });
const bCarriedBefore = (await status(B)).app?.links_carried;
record('B: before its move, v1.5 links its key to no identity', bBefore.ok && bBefore.data === false && bCarriedBefore === false,
bBefore.error || JSON.stringify({ linked: bBefore.data, links_carried: bCarriedBefore }));
const anon = await op(A, { op: 'v13_poll', title: `carrier anonymous ${nowS()}`, description: 'by an install that never updates', options: ['red', 'blue'], closes_at: nowS() + 7 * 86400 });
const pub = await op(A, { op: 'v13_poll', title: `carrier public ${nowS()}`, description: 'names shown', options: ['yes', 'no'], closes_at: nowS() + 7 * 86400, poll_type: 'Public' });
record('A: two v1.3 polls, one anonymous and one public', anon.ok && pub.ok, anon.error || pub.error);
Expand Down Expand Up @@ -419,6 +430,10 @@ async function carrier() {
record('B: sees the carried votes on both copies', await until(async () => ((await v15(B, 'get_poll_votes', anonCopy)) || []).length >= 3 && ((await v15(B, 'get_poll_votes', pubCopy)) || []).length >= 1, 300, 10000));
const mv = await op(B, { op: 'move15' }, 600);
record("B: its own move runs, nothing refused", mv.ok && mv.data?.report?.failed === 0, mv.error || JSON.stringify(mv.data?.report));
record('B: its move carried its links', (await status(B)).app?.links_carried === true);
let bAfter = { ok: false };
record('B: its carried v1.3 link links its key to the identity on v1.5 (no new link to ask the Vault for)',
await until(async () => (bAfter = await op(B, { op: 'linked_active' })).ok && bAfter.data === true, 120, 5000), bAfter.error || JSON.stringify(bAfter.data));
const afterMove = (await v15(C, 'get_poll_votes', anonCopy)) || [];
record('the anonymous poll still has three votes', afterMove.length === 3, JSON.stringify(by(afterMove)));
await signin(B, 'B');
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/carry_move.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ use std::sync::Arc;
const POLLS: &str = "polls";
const STATE_FILE: &str = "carry-move-v1_5.json";

/// This start's agent links are on v1.5: a run carried every one of them,
/// or the move was already complete. Until then the identity link this
/// install made on an older version may not be on v1.5 yet, so nothing
/// decides from v1.5's links that a new one is needed.
static LINKS_CARRIED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

pub fn links_carried() -> bool {
LINKS_CARRIED.load(std::sync::atomic::Ordering::Relaxed)
}

fn set_links_carried() {
LINKS_CARRIED.store(true, std::sync::atomic::Ordering::Relaxed);
}

/// What the move keeps between runs.
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
pub struct MoveState {
Expand Down Expand Up @@ -326,6 +340,7 @@ pub async fn run(state: &Arc<AppState>) -> Result<Report, String> {
let mut ms = MoveState::load(&state.data_dir);
let mut report = Report::default();
if ms.complete {
set_links_carried();
return Ok(report);
}
let v13 = own_v13(state).await?;
Expand All @@ -350,6 +365,9 @@ pub async fn run(state: &Arc<AppState>) -> Result<Report, String> {
};
count(r, &mut report.links, &mut report.waiting, &mut report.failed, "an agent link");
}
if report.waiting == 0 && report.failed == 0 {
set_links_carried();
}
for c in &plan.first {
let r = match carried::check(c) {
Ok(_) => call_hash(dest, "carry_poll", &CarryInput { carried: c.clone() }).await.map(|_| true),
Expand Down
104 changes: 96 additions & 8 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,8 @@ pub struct AppStatus {
pub conductor_status: ConductorStatus,
/// This launch was asked for by "Open as this identity".
pub relaunched_into_identity: bool,
/// This start's agent links are on v1.5 (`carry_move::links_carried`).
pub links_carried: bool,
}

#[tauri::command]
Expand All @@ -555,6 +557,7 @@ pub fn get_app_status(state: tauri::State<'_, std::sync::Arc<AppState>>) -> AppS
relaunched_into_identity: state
.relaunched_into_identity
.load(std::sync::atomic::Ordering::Relaxed),
links_carried: crate::carry_move::links_carried(),
}
}

Expand Down Expand Up @@ -1494,7 +1497,14 @@ pub async fn commit_identity_link(
if let Err(e) =
call_zome(client, AGENT_LINKING_ZOME, "revoke_link", payload).await
{
log::warn!("could not revoke the superseded identity link: {}", e);
// A re-link on a newer network version: the
// previous link is on the older one and stays
// there with it.
if e.contains("Entry not found") {
log::info!("the superseded identity link is on an older network version; left there");
} else {
log::warn!("could not revoke the superseded identity link: {}", e);
}
}
}
}
Expand Down Expand Up @@ -1889,17 +1899,40 @@ async fn fold_siblings_on(
}
}

/// The agents linked to `agent_pub_key` on the active network (v1.5) only,
/// natively (a carried link does not count), for the launch check that
/// links this install's key there. `get_linked_agents` also counts the older
/// networks, so that check never ran.
/// Whether the active network (v1.5) links `agent_pub_key` to the identity
/// this install is signed in with: by a link made there, or by one carried
/// there. A carried link counts only for a key its original names (v1.5
/// files it under those two keys alone and validates that), so a link made
/// for the install's other key never stands in for this one. Without a
/// saved link to name the identity, any link of this key there counts. Err
/// while that network does not answer: the caller asks again later rather
/// than deciding from nothing. Older networks are not read: a link there
/// does not let v1.5 peers see who wrote what.
#[tauri::command]
pub async fn get_linked_agents_active(
pub async fn identity_linked_on_active(
state: tauri::State<'_, std::sync::Arc<AppState>>,
agent_pub_key: String,
) -> Result<Vec<String>, String> {
) -> Result<bool, String> {
let agent = AgentPubKey::try_from(agent_pub_key).map_err(|e| format!("Invalid agent key: {:?}", e))?;
Ok(linked_agent_keys(state.inner(), &agent).await?.iter().map(|a| a.to_string()).collect())
identity_linked_on_active_inner(state.inner(), &agent).await
}

pub(crate) async fn identity_linked_on_active_inner(
state: &std::sync::Arc<AppState>,
agent: &AgentPubKey,
) -> Result<bool, String> {
let linked = linked_on(state, "active", agent).await?.ok_or("The active network is not running yet")?;
let identity = load_identity_link(&state.data_dir).and_then(|l| parse_agent_pub_key_string(&l.vault_agent_pub_key).ok());
Ok(links_this_identity(&linked, identity.as_ref()))
}

/// By the key itself: the saved identity key comes from the Vault as text,
/// and its location bytes need not match the network's copy.
fn links_this_identity(linked: &[AgentPubKey], identity: Option<&AgentPubKey>) -> bool {
match identity {
Some(identity) => linked.iter().any(|k| k.get_raw_32() == identity.get_raw_32()),
None => !linked.is_empty(),
}
}

#[tauri::command]
Expand Down Expand Up @@ -3172,6 +3205,34 @@ pub(crate) async fn harness_v13_vote(state: &AppState, poll: String, option_inde
Ok(action_hash.to_string())
}

/// See `harness_v13_poll`: a sign-in as a 0.4.x install made it, the
/// identity link written on v1.3 and saved here, so the move's tests start
/// from an install whose only link is on an older version.
#[cfg(feature = "harness")]
pub(crate) async fn harness_v13_link(state: &AppState, vault_agent_pub_key: String, vault_signature: String) -> Result<String, String> {
#[derive(serde::Serialize, Debug)]
struct CreateExternalLinkInput {
external_agent: AgentPubKey,
external_signature: Vec<u8>,
}
use base64::Engine;
let external_signature = base64::engine::general_purpose::STANDARD
.decode(&vault_signature)
.map_err(|e| format!("Invalid signature: {}", e))?;
let input = CreateExternalLinkInput { external_agent: parse_agent_pub_key_string(&vault_agent_pub_key)?, external_signature };
let client = state.app_client_v1_3.lock().await;
let client = client.as_ref().ok_or("v1.3 is not running")?;
let result = call_zome(client, AGENT_LINKING_ZOME, "create_external_link", crate::hc::encode(&input)?).await?;
let action_hash: ActionHash = crate::hc::decode(&result)?;
let linked_at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
save_identity_link(
&state.data_dir,
&IdentityLinkData { vault_agent_pub_key: vault_agent_pub_key.clone(), entry_action_hash: action_hash.to_string(), linked_at },
);
crate::profiles::bind_profile(&state.device_root, &state.data_dir, &vault_agent_pub_key);
Ok(action_hash.to_string())
}

/// See `harness_v13_poll`: a flag on a v1.3 poll, raised there with no
/// identity gate.
#[cfg(feature = "harness")]
Expand Down Expand Up @@ -3266,3 +3327,30 @@ mod backup_tests {
assert!(Copied06::from(&state).holds("EncryptedEntry", DRAFT_06, None));
}
}

#[cfg(test)]
mod active_link_tests {
use super::links_this_identity;
use holochain_types::prelude::AgentPubKey;

#[test]
fn the_active_link_counts_for_the_signed_in_identity_only() {
let identity = AgentPubKey::from_raw_32(vec![7; 32]);
let other_identity = AgentPubKey::from_raw_32(vec![8; 32]);
assert!(links_this_identity(&[identity.clone()], Some(&identity)));
assert!(links_this_identity(&[other_identity.clone(), identity.clone()], Some(&identity)));
assert!(!links_this_identity(&[other_identity.clone()], Some(&identity)), "a link to an identity this install left does not count");
assert!(!links_this_identity(&[], Some(&identity)));

// The saved key arrives as text from the Vault: compared by the key
// itself, whatever its location bytes say.
let mut raw = identity.get_raw_39().to_vec();
raw[35..39].copy_from_slice(&[0, 0, 0, 0]);
let as_saved = AgentPubKey::from_raw_39(raw);
assert!(links_this_identity(&[identity.clone()], Some(&as_saved)));

// No saved link to name the identity: any link of the key counts.
assert!(links_this_identity(&[other_identity], None));
assert!(!links_this_identity(&[], None));
}
}
67 changes: 50 additions & 17 deletions src-tauri/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,18 @@ async fn op(Ax(app): Ax<tauri::AppHandle>, Json(body): Json<Value>) -> Reply {
"identity" => ok(serde_json::to_value(commands::get_identity_link(state)).unwrap_or(Value::Null)),
"migration" => to_value(commands::get_migration_status(state).await),
"link" => link(&app, state, s(&body, "client_id")).await,
// A sign-in as 0.4.x made it: the link on v1.3 only.
"v13_link" => v13_link(state, s(&body, "client_id")).await,
// The layout's check: v1.5 links the key the active network runs
// as to the signed-in identity (own or carried link).
"linked_active" => {
let st = commands::get_app_status(state.clone());
match st.agent_pub_key_07.or(st.agent_pub_key).map(holochain_types::prelude::AgentPubKey::try_from) {
Some(Ok(key)) => to_value(commands::identity_linked_on_active_inner(state.inner(), &key).await),
Some(Err(e)) => failed(format!("{:?}", e)),
None => failed("no local agent yet"),
}
}
// As the UI asks it: from this install's own agent key.
"agent_set" => {
let local = commands::get_app_status(state.clone()).agent_pub_key;
Expand Down Expand Up @@ -517,31 +529,52 @@ async fn backup(state: tauri::State<'_, Arc<AppState>>, client_id: String) -> Re
}
}

/// The sign-in the identity page does through the SDK: the Vault's
/// `/link-identity` (the Vault the instance is pinned to), then the commit.
async fn link(app: &tauri::AppHandle, state: tauri::State<'_, Arc<AppState>>, client_id: String) -> Reply {
let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else {
return failed("no local agent yet");
};
/// The Vault's signature for this install's key, as `/link-identity` gives
/// it to the SDK: (identity key, signature).
async fn vault_signs_link(agent: &str, client_id: &str) -> Result<(String, String), String> {
let base = match crate::vault_probe::test_vault_port() {
Some(p) => format!("http://127.0.0.1:{}", p),
None => return failed("no PROOFPOLL_TEST_VAULT_PORT"),
None => return Err("no PROOFPOLL_TEST_VAULT_PORT".into()),
};
let _ = app;
let resp = reqwest::Client::new()
let r = reqwest::Client::new()
.post(format!("{}/link-identity", base))
.header("Origin", crate::vault_probe::webview_origin())
.json(&json!({ "app_name": "ProofPoll", "client_id": client_id, "app_agent_pub_key": agent }))
.timeout(std::time::Duration::from_secs(120))
.send()
.await;
let v: Value = match resp {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or(Value::Null),
Ok(r) => return failed(format!("link-identity {}: {}", r.status(), r.text().await.unwrap_or_default())),
Err(e) => return failed(e),
.await
.map_err(|e| e.to_string())?;
if !r.status().is_success() {
return Err(format!("link-identity {}: {}", r.status(), r.text().await.unwrap_or_default()));
}
let v: Value = r.json().await.unwrap_or(Value::Null);
match (v.get("vault_agent_pub_key").and_then(|x| x.as_str()), v.get("vault_signature").and_then(|x| x.as_str())) {
(Some(key), Some(sig)) => Ok((key.to_string(), sig.to_string())),
_ => Err(format!("link-identity answered without a key and signature: {}", v)),
}
}

/// A sign-in as ProofPoll 0.4.x made it: the Vault signs this install's
/// key and the link is written on v1.3 (and saved here), nowhere else.
async fn v13_link(state: tauri::State<'_, Arc<AppState>>, client_id: String) -> Reply {
let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else {
return failed("no local agent yet");
};
let (Some(key), Some(sig)) = (v.get("vault_agent_pub_key").and_then(|x| x.as_str()), v.get("vault_signature").and_then(|x| x.as_str())) else {
return failed(format!("link-identity answered without a key and signature: {}", v));
match vault_signs_link(&agent, &client_id).await {
Ok((key, sig)) => to_value(commands::harness_v13_link(&state, key.clone(), sig).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h }))),
Err(e) => failed(e),
}
}

/// The sign-in the identity page does through the SDK: the Vault's
/// `/link-identity` (the Vault the instance is pinned to), then the commit.
async fn link(app: &tauri::AppHandle, state: tauri::State<'_, Arc<AppState>>, client_id: String) -> Reply {
let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else {
return failed("no local agent yet");
};
to_value(commands::commit_identity_link(state, key.to_string(), sig.to_string()).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h })))
let _ = app;
match vault_signs_link(&agent, &client_id).await {
Ok((key, sig)) => to_value(commands::commit_identity_link(state, key.clone(), sig).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h }))),
Err(e) => failed(e),
}
}
2 changes: 1 addition & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ pub fn run() {
commands::save_profile_cache,
commands::commit_identity_link,
commands::get_linked_agents,
commands::get_linked_agents_active,
commands::identity_linked_on_active,
commands::get_my_agent_set,
commands::get_identity_link,
commands::revoke_identity_link,
Expand Down
12 changes: 8 additions & 4 deletions src/lib/holochain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,15 @@ export async function getLinkedAgents(
});
}

/** The agents linked on the active network only (v1.4), not v1.3 too. */
export async function getLinkedAgentsActive(
/**
* Whether the active network (v1.5) links this key to the identity this
* install is signed in with, by a link made there or one carried there for
* this key. Rejects while that network does not answer.
*/
export async function identityLinkedOnActive(
agentPubKey: string,
): Promise<string[]> {
return invoke<string[]>("get_linked_agents_active", {
): Promise<boolean> {
return invoke<boolean>("identity_linked_on_active", {
agentPubKey,
});
}
Expand Down
Loading
Loading