From 93d66d69e683ffc10efd14f7da6b8b58898a0741 Mon Sep 17 00:00:00 2001
From: Michael Yankelev
Date: Mon, 14 Sep 2026 16:19:44 +0200
Subject: [PATCH] feat: keep a stored provider credential across an unrelated
settings save
The vault settings record round-tripped every field but the provider bearer. The wasm boundary is write-only for that credential, so a read reports only that one is stored. A save publishes the record whole, which made a blank credential field mean no bearer and turned every unrelated settings change into a credential loss.
The bearer is now three-state at every layer. ByoBearer::Keep is a save intent alone: it names no bytes, validate_byo_config refuses it on the encode path and on every request path, and Engine::save_vault_settings resolves it from the provider config the session placement already holds.
A kept bearer is bound to the provider it was stored for. The save must name the same endpoint and the same kind, or it is refused. The keep intent crosses an untrusted realm boundary, so without that binding a host could carry a credential it cannot read on to an endpoint it chose and have the next placement present the member's bearer there.
The wasm constructor refuses a keep intent that also carries bytes, and zeroizes those bytes before it refuses. The web settings form sends the keep intent when the credential field is untouched and the provider is unchanged, which replaces the refusal that asked the member to retype the credential for every save.
---
.../settings/VaultSettingsForm.test.tsx | 26 +++-
.../components/settings/VaultSettingsForm.tsx | 23 ++-
apps/web/src/settings/vaultSettings.test.ts | 26 +++-
apps/web/src/settings/vaultSettings.ts | 15 +-
crates/engine/src/content/mod.rs | 2 +-
crates/engine/src/content/provider.rs | 87 +++++++++--
crates/engine/src/facade.rs | 26 +++-
crates/engine/src/lib.rs | 14 +-
crates/engine/src/settings.rs | 138 ++++++++++++++++-
crates/engine/src/sync/drain.rs | 7 +-
crates/engine/src/sync/upload_mark.rs | 4 +-
crates/engine/tests/vault_settings.rs | 139 +++++++++++++++++-
crates/engine/tests/write_plane.rs | 6 +-
crates/wasm/src/lib.rs | 26 +++-
crates/wasm/tests/boundary.rs | 22 +++
.../client/src/broadcastTransport.test.ts | 5 +-
packages/client/src/facade.ts | 4 +-
packages/client/src/index.ts | 1 +
packages/client/src/settings/quota.test.ts | 62 ++++++--
packages/client/src/settings/quota.ts | 43 ++++--
.../client/src/worker/commandCodec.test.ts | 26 +++-
packages/client/src/worker/commandCodec.ts | 38 +++--
packages/client/src/worker/engineWasm.ts | 3 +-
packages/client/src/worker/protocol.ts | 16 +-
24 files changed, 637 insertions(+), 122 deletions(-)
diff --git a/apps/web/src/components/settings/VaultSettingsForm.test.tsx b/apps/web/src/components/settings/VaultSettingsForm.test.tsx
index 4de9587cc4..3036f6b1df 100644
--- a/apps/web/src/components/settings/VaultSettingsForm.test.tsx
+++ b/apps/web/src/components/settings/VaultSettingsForm.test.tsx
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
+import { KEEP_STORED_BEARER } from '@cipherbox/client';
import type { VaultSettingsSummaryDescriptor } from '@cipherbox/client';
import { describe, expect, it } from 'vitest';
import { VaultSettingsForm } from './VaultSettingsForm';
@@ -123,8 +124,8 @@ describe('the vault settings form', () => {
// The fake takes the descriptor in-process rather than transferring it, so
// the buffer is still this realm's to scrub — as a refused dispatch leaves it.
- const carried = taking.saves[0].byo?.accessToken;
- expect(new Uint8Array(carried!)).toEqual(new Uint8Array('opaque'.length));
+ const carried = taking.saves[0].byo?.accessToken as ArrayBuffer;
+ expect(new Uint8Array(carried)).toEqual(new Uint8Array('opaque'.length));
});
it('scrubs the bearer the engine refused rather than leaving it in memory', async () => {
@@ -134,8 +135,8 @@ describe('the vault settings form', () => {
type('provider access token', 'opaque');
await save();
- const carried = taking.saves[0].byo?.accessToken;
- expect(new Uint8Array(carried!)).toEqual(new Uint8Array('opaque'.length));
+ const carried = taking.saves[0].byo?.accessToken as ArrayBuffer;
+ expect(new Uint8Array(carried)).toEqual(new Uint8Array('opaque'.length));
});
it('sends nothing until the member takes on replacing the whole record', () => {
@@ -225,14 +226,27 @@ describe('the vault settings form', () => {
});
describe('a save over a credential the form cannot show', () => {
- it('refuses to blank a stored credential as a side effect of an unrelated edit', async () => {
+ it('keeps a stored credential through an unrelated edit', async () => {
const taking = renderForm(engineTaking(), WITH_CREDENTIAL);
type('keep newest versions', '5');
await save();
+ expect(taking.saves).toHaveLength(1);
+ expect(taking.saves[0].byo?.accessToken).toBe(KEEP_STORED_BEARER);
+ expect(taking.saves[0].keepLatestVersions).toBe(5);
+ });
+
+ // The stored bearer belongs to the provider it was stored for. A form that
+ // kept it on to another endpoint would hand the credential to that endpoint.
+ it('refuses to keep a stored credential on to a repointed provider', async () => {
+ const taking = renderForm(engineTaking(), WITH_CREDENTIAL);
+
+ type('your ipfs provider', 'https://elsewhere.example');
+ await save();
+
expect(taking.saves).toEqual([]);
- expect(screen.getByTestId('settings-error').textContent).toMatch(/credential/i);
+ expect(screen.getByTestId('settings-error').textContent).toMatch(/different provider/i);
});
it('clears the stored credential where the member asks for exactly that', async () => {
diff --git a/apps/web/src/components/settings/VaultSettingsForm.tsx b/apps/web/src/components/settings/VaultSettingsForm.tsx
index dcd35bfda3..af66e3ab9a 100644
--- a/apps/web/src/components/settings/VaultSettingsForm.tsx
+++ b/apps/web/src/components/settings/VaultSettingsForm.tsx
@@ -1,5 +1,10 @@
import { useEffect, useState } from 'react';
-import { originNotice, prefillFromSummary, settingsSaveVerdict } from '@cipherbox/client';
+import {
+ KEEP_STORED_BEARER,
+ originNotice,
+ prefillFromSummary,
+ settingsSaveVerdict,
+} from '@cipherbox/client';
import type { ByoKind, PinMode, VaultSettingsSummaryDescriptor } from '@cipherbox/client';
import { useCommandRunner } from '../../hooks/useCommandRunner';
import {
@@ -39,7 +44,7 @@ const BYO_KINDS: { value: ByoKind; label: string }[] = [
* credential is not: it is the one field the wasm boundary keeps write-only, so
* a stored bearer never crosses into JS (`crates/wasm/src/lib.rs`). A save
* replaces the whole record with what is on the form, so `settingsSaveVerdict`
- * refuses the two shapes that destroy a choice the member did not edit.
+ * decides how the one field the form cannot show is spelled.
*/
export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps) {
const [fields, setFields] = useState(DEFAULT_VAULT_SETTINGS_FORM);
@@ -85,7 +90,10 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
origin,
credentialStored,
byoEndpoint: fields.byoEndpoint,
+ byoKind: fields.byoKind,
byoAccessToken: fields.byoAccessToken,
+ storedEndpoint: summary.byoEndpoint,
+ storedKind: summary.byoKind,
clearCredential,
loadAcknowledged,
});
@@ -93,7 +101,7 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
setProblem(verdict.problem);
return;
}
- const draft = buildVaultSettings(fields);
+ const draft = buildVaultSettings(fields, verdict.keepStoredCredential);
setProblem(draft.ok ? null : draft.problem);
if (!draft.ok) return;
void run('saveVaultSettings', (facade) => facade.saveVaultSettings(draft.settings)).then(
@@ -101,7 +109,9 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
// The form is the bearer's terminal owner: a send transfers the buffer
// out and detaches it, so a still-readable one never left this realm.
const bearer = draft.settings.byo?.accessToken;
- if (bearer && bearer.byteLength > 0) new Uint8Array(bearer).fill(0);
+ if (bearer != null && bearer !== KEEP_STORED_BEARER && bearer.byteLength > 0) {
+ new Uint8Array(bearer).fill(0);
+ }
setSaved(accepted);
// The bearer is spent by the send that carried it; a retry types it
// again rather than re-sending a buffer this realm no longer owns.
@@ -228,7 +238,7 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
: '// the engine never reads a provider credential back out,'}
{credentialStored
- ? '// so keeping the provider means typing it again — or clearing it outright.'
+ ? '// so leave this blank to keep it. it is kept only for the provider above.'
: '// so this field is the only place one can be set.'}
)}
@@ -270,8 +280,7 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
onChange={(event) => setAcknowledged(event.target.checked)}
/>
- i understand saving replaces every stored setting with exactly what is on this form,
- including the provider credential this form cannot show me
+ i understand saving replaces every stored setting with exactly what is on this form
diff --git a/apps/web/src/settings/vaultSettings.test.ts b/apps/web/src/settings/vaultSettings.test.ts
index d2030f3993..51515e61ac 100644
--- a/apps/web/src/settings/vaultSettings.test.ts
+++ b/apps/web/src/settings/vaultSettings.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { KEEP_STORED_BEARER } from '@cipherbox/client';
import {
buildVaultSettings,
DEFAULT_VAULT_SETTINGS_FORM,
@@ -42,9 +43,28 @@ describe('the vault settings a save publishes', () => {
buildVaultSettings(form({ byoEndpoint: 'https://kubo.example', byoAccessToken: 'opaque' }))
);
- const carried = built.byo?.accessToken;
- expect(carried?.byteLength).toBe('opaque'.length);
- expect(new TextDecoder().decode(new Uint8Array(carried!))).toBe('opaque');
+ const carried = built.byo?.accessToken as ArrayBuffer;
+ expect(carried.byteLength).toBe('opaque'.length);
+ expect(new TextDecoder().decode(new Uint8Array(carried))).toBe('opaque');
+ });
+
+ it('spells a kept credential as the keep intent, never as bytes', () => {
+ const built = settings(buildVaultSettings(form({ byoEndpoint: 'https://kubo.example' }), true));
+
+ expect(built.byo?.accessToken).toBe(KEEP_STORED_BEARER);
+ });
+
+ // The keep intent must not smuggle a typed bearer past the engine's
+ // same-provider binding: the form sends one or the other, never both.
+ it('drops a typed bearer when the save keeps the stored one', () => {
+ const built = settings(
+ buildVaultSettings(
+ form({ byoEndpoint: 'https://kubo.example', byoAccessToken: 'opaque' }),
+ true
+ )
+ );
+
+ expect(built.byo?.accessToken).toBe(KEEP_STORED_BEARER);
});
it('mints a fresh bearer buffer per build, because the send detaches it', () => {
diff --git a/apps/web/src/settings/vaultSettings.ts b/apps/web/src/settings/vaultSettings.ts
index e61a4050ee..f4b3143c0d 100644
--- a/apps/web/src/settings/vaultSettings.ts
+++ b/apps/web/src/settings/vaultSettings.ts
@@ -8,6 +8,7 @@
* what an empty one means.
*/
+import { KEEP_STORED_BEARER } from '@cipherbox/client';
import type { ByoKind, PinMode, VaultSettingsDescriptor } from '@cipherbox/client';
/**
@@ -49,8 +50,14 @@ export type VaultSettingsDraft =
* Builds the descriptor for one save. Called per dispatch, never cached: the
* bearer rides a transferable buffer that `saveVaultSettings` detaches, so a
* descriptor sent twice would carry a spent credential the second time.
+ *
+ * `keepStoredCredential` is `settingsSaveVerdict`'s answer: it is the only way
+ * a form that can never read a stored bearer publishes without destroying it.
*/
-export function buildVaultSettings(form: VaultSettingsFields): VaultSettingsDraft {
+export function buildVaultSettings(
+ form: VaultSettingsFields,
+ keepStoredCredential = false
+): VaultSettingsDraft {
const keep = form.keepLatestVersions.trim();
if (keep !== '' && !isCount(keep)) {
return {
@@ -73,7 +80,11 @@ export function buildVaultSettings(form: VaultSettingsFields): VaultSettingsDraf
byo:
endpoint === ''
? null
- : { endpoint, kind: form.byoKind, accessToken: bearer(form.byoAccessToken) },
+ : {
+ endpoint,
+ kind: form.byoKind,
+ accessToken: keepStoredCredential ? KEEP_STORED_BEARER : bearer(form.byoAccessToken),
+ },
keepLatestVersions: keep === '' ? null : Number(keep),
binRetentionDays: Number(binDays),
},
diff --git a/crates/engine/src/content/mod.rs b/crates/engine/src/content/mod.rs
index ae7675a303..af31e353c6 100644
--- a/crates/engine/src/content/mod.rs
+++ b/crates/engine/src/content/mod.rs
@@ -28,7 +28,7 @@ pub use dag::{
pub use profile::ContentProfile;
pub(crate) use provider::place_block;
pub use provider::{
- ByoIpfsConfig, ByoKind, PinMode, ProviderError, test_connection, validate_byo_config,
+ ByoBearer, ByoIpfsConfig, ByoKind, PinMode, ProviderError, test_connection, validate_byo_config,
};
pub use read::{
ContentPlane, Gateway, GatewayConfig, GatewayOnly, GatewaySource, LocalBlocks, ReadError,
diff --git a/crates/engine/src/content/provider.rs b/crates/engine/src/content/provider.rs
index ae975d7d1c..cb47adb8cb 100644
--- a/crates/engine/src/content/provider.rs
+++ b/crates/engine/src/content/provider.rs
@@ -66,6 +66,43 @@ pub enum ByoKind {
Pinata,
}
+/// What a provider config says about its bearer credential. Three-state so a
+/// host that can never read a stored bearer back can still say "leave it
+/// alone"; two states make every unrelated settings save destroy it.
+///
+/// [`Self::Keep`] is a save intent and names no bytes, so [`validate_byo_config`]
+/// refuses it on the encode path and on every request path.
+#[derive(Clone, PartialEq, Eq)]
+pub enum ByoBearer {
+ /// The provider needs no credential, or the member cleared the stored one.
+ None,
+ /// This bearer. Zeroized on drop, never logged.
+ Set(Zeroizing),
+ /// Keep the bearer the session already holds.
+ Keep,
+}
+
+impl ByoBearer {
+ /// The bearer this config names, or `None` for a config that names none.
+ #[must_use]
+ pub fn token(&self) -> Option<&Zeroizing> {
+ match self {
+ Self::Set(token) => Some(token),
+ Self::None | Self::Keep => None,
+ }
+ }
+}
+
+impl fmt::Debug for ByoBearer {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(match self {
+ Self::None => "None",
+ Self::Set(_) => "Set()",
+ Self::Keep => "Keep",
+ })
+ }
+}
+
/// A member's bring-your-own IPFS provider config. Stays sealed in vault
/// settings (blueprint/engine.md); this is the plaintext the seal wraps. The
/// access token is a credential: held in a zeroizing buffer and redacted from
@@ -77,8 +114,8 @@ pub struct ByoIpfsConfig {
/// The provider kind, selecting the reachability probe.
pub kind: ByoKind,
/// Bearer credential, when the provider requires one (PSA/Pinata always;
- /// Kubo when fronted by an auth proxy). Zeroized on drop, never logged.
- pub access_token: Option>,
+ /// Kubo when fronted by an auth proxy).
+ pub access_token: ByoBearer,
}
impl fmt::Debug for ByoIpfsConfig {
@@ -86,10 +123,7 @@ impl fmt::Debug for ByoIpfsConfig {
f.debug_struct("ByoIpfsConfig")
.field("endpoint", &self.endpoint)
.field("kind", &self.kind)
- .field(
- "access_token",
- &self.access_token.as_ref().map(|_| ""),
- )
+ .field("access_token", &self.access_token)
.finish()
}
}
@@ -276,7 +310,7 @@ fn base(config: &ByoIpfsConfig) -> &str {
/// body. The configured access token is the only credential a BYO endpoint gets.
fn headers(config: &ByoIpfsConfig, content_type: Option) -> Vec<(String, String)> {
let mut headers = Vec::new();
- if let Some(token) = &config.access_token {
+ if let Some(token) = config.access_token.token() {
headers.push((
AUTHORIZATION.to_owned(),
format!("Bearer {}", token.as_str()),
@@ -308,6 +342,18 @@ pub enum ProviderError {
BlockedAddress,
/// The access token carries bytes a header value may not.
InvalidCredential,
+ /// The config still carries [`ByoBearer::Keep`]. Only a save resolves that
+ /// intent, so anything else holding it would send or seal a provider with
+ /// no credential at all (AGENTS.md rule 8).
+ UnresolvedCredential,
+ /// A save asked to keep the stored bearer and this session holds none, so
+ /// keeping would publish a credential-free provider rather than the one the
+ /// member has.
+ NoStoredCredential,
+ /// A save asked to keep the stored bearer while naming a different endpoint
+ /// or kind. A bearer is kept for the provider it was stored for, never
+ /// carried on to another one.
+ RepointedCredential,
/// The provider could not be reached (transport-level failure).
Unreachable,
/// The provider answered, but with nothing that says what it did.
@@ -335,6 +381,9 @@ impl ProviderError {
ProviderError::InsecureTransport => "byo-endpoint-insecure",
ProviderError::BlockedAddress => "byo-endpoint-blocked",
ProviderError::InvalidCredential => "byo-credential-invalid",
+ ProviderError::UnresolvedCredential => "byo-credential-unresolved",
+ ProviderError::NoStoredCredential => "byo-credential-not-stored",
+ ProviderError::RepointedCredential => "byo-credential-repointed",
ProviderError::Unreachable => "byo-unreachable",
ProviderError::NoVerdict => "byo-no-verdict",
ProviderError::Rejected { .. } => "byo-rejected",
@@ -354,6 +403,9 @@ impl ProviderError {
| ProviderError::InsecureTransport
| ProviderError::BlockedAddress
| ProviderError::InvalidCredential
+ | ProviderError::UnresolvedCredential
+ | ProviderError::NoStoredCredential
+ | ProviderError::RepointedCredential
)
}
}
@@ -390,8 +442,11 @@ pub fn validate_byo_config(config: &ByoIpfsConfig) -> Result<(), ProviderError>
match &config.access_token {
// `None` is how a credential-less provider is spelled, so it is not a
// verdict; a token that is present must be sendable as a header value.
- Some(token) => check_bearer(token.as_str()).map_err(|_| ProviderError::InvalidCredential),
- None => Ok(()),
+ ByoBearer::Set(token) => {
+ check_bearer(token.as_str()).map_err(|_| ProviderError::InvalidCredential)
+ }
+ ByoBearer::None => Ok(()),
+ ByoBearer::Keep => Err(ProviderError::UnresolvedCredential),
}
}
@@ -533,11 +588,17 @@ mod tests {
use crate::testkit::block_on;
use crate::testkit::fakes::ScriptedHttp;
+ fn bearer(token: Option<&str>) -> ByoBearer {
+ token.map_or(ByoBearer::None, |t| {
+ ByoBearer::Set(Zeroizing::new(t.to_owned()))
+ })
+ }
+
fn config(kind: ByoKind, token: Option<&str>) -> ByoIpfsConfig {
ByoIpfsConfig {
endpoint: "https://ipfs.member.test/".into(),
kind,
- access_token: token.map(|t| Zeroizing::new(t.to_owned())),
+ access_token: bearer(token),
}
}
@@ -679,7 +740,7 @@ mod tests {
let cfg = ByoIpfsConfig {
endpoint: bad.into(),
kind: ByoKind::Psa,
- access_token: None,
+ access_token: ByoBearer::None,
};
assert_eq!(
block_on(test_connection(&cfg, &http, &DeadlinePolicy::default())).unwrap_err(),
@@ -710,7 +771,7 @@ mod tests {
let cfg = ByoIpfsConfig {
endpoint: endpoint.to_owned(),
kind: ByoKind::Kubo,
- access_token: None,
+ access_token: ByoBearer::None,
};
block_on(test_connection(&cfg, &http, &DeadlinePolicy::default())).unwrap();
assert_eq!(http.requests()[0].url, format!("{endpoint}/api/v0/id"));
@@ -1002,7 +1063,7 @@ mod tests {
let bad = ByoIpfsConfig {
endpoint: "http://169.254.169.254".into(),
kind: ByoKind::Kubo,
- access_token: None,
+ access_token: ByoBearer::None,
};
assert_eq!(
block_on(place_block(
diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs
index 1dc420d399..2104668df9 100644
--- a/crates/engine/src/facade.rs
+++ b/crates/engine/src/facade.rs
@@ -48,9 +48,9 @@ use crate::content::budget::{Refusal, ReservationId};
use crate::content::limits::folder_listing_budget;
use crate::content::read::authority_of;
use crate::content::{
- ContentKey, ContentProfile, ContentWriter, Gateway, GatewayConfig, OpenError, PinMode, Refused,
- RootManifest, SealError, SessionBearer, StagingLedger, open_content_range, open_content_root,
- pre_flight_quota_check, read_pinned_range, sealed_total_bytes,
+ ByoIpfsConfig, ContentKey, ContentProfile, ContentWriter, Gateway, GatewayConfig, OpenError,
+ PinMode, Refused, RootManifest, SealError, SessionBearer, StagingLedger, open_content_range,
+ open_content_root, pre_flight_quota_check, read_pinned_range, sealed_total_bytes,
};
use crate::deadlines::DeadlinePolicy;
use crate::devices::{self, ApprovalDecision, MalformedDeviceField, PendingApprovalView};
@@ -114,10 +114,10 @@ use crate::seams::{
};
use crate::session::SessionIdentity;
use crate::settings::{
- DEFAULT_BIN_RETENTION_DAYS, PlacementRefusal, PlacementSource, SessionPlacement,
+ DEFAULT_BIN_RETENTION_DAYS, Placement, PlacementRefusal, PlacementSource, SessionPlacement,
SettingsOrigin, SettingsPublishError, VaultSettings, VaultSettingsSummary, decide_placement,
load_settings, load_settings_at, placement_of, publish_settings, redecide_placement,
- summarize_settings,
+ resolve_kept_bearer, summarize_settings,
};
use crate::storage_policy::StoragePolicy;
use crate::sync::boot::{ColdStartError, ColdStartOutcome, ColdStartParams, cold_start};
@@ -8678,12 +8678,24 @@ where {
)
}
+ /// The provider config this session holds, which is the one its placement
+ /// authorises: a session placing no bytes on the member's own provider
+ /// keeps no credential for it (security rule 7).
+ fn held_provider(&self) -> Option {
+ match &self.placement.borrow().as_ref()?.decision {
+ Ok(Placement::External(config) | Placement::Dual(config)) => Some(config.clone()),
+ Ok(Placement::Hosted) | Err(_) => None,
+ }
+ }
+
/// Seal and publish the vault settings record, then adopt what it
/// published: the renewal enrolment [`publish_settings`] states the need
/// for, and the placement this session writes under.
async fn save_vault_settings(&self, settings: &VaultSettings) -> Result<(), EngineError> {
let session = self.session.as_ref().ok_or(EngineError::NotStarted)?;
let api = self.api.as_ref().ok_or(EngineError::NotStarted)?;
+ let settings = &resolve_kept_bearer(settings, self.held_provider().as_ref())
+ .map_err(|e| EngineError::from_settings_publish(SettingsPublishError::Byo(e)))?;
let held = publish_settings(
&self.record_transport,
api,
@@ -11475,7 +11487,7 @@ mod tests {
use core::num::NonZeroU64;
use crate::api::{ChallengeSigner, new_user_login_response};
- use crate::content::{ByoIpfsConfig, ByoKind, RetentionPolicy};
+ use crate::content::{ByoBearer, ByoIpfsConfig, ByoKind, RetentionPolicy};
use crate::net::retire::ReclaimStallReason;
use crate::seams::{CredentialStore, EndpointId, HttpMethod, HttpResponse, UnixMillis};
use crate::settings::{cached_settings_block, settings_name};
@@ -12576,7 +12588,7 @@ mod tests {
byo: Some(ByoIpfsConfig {
endpoint: "https://node.example".to_owned(),
kind: ByoKind::Kubo,
- access_token: Some(Zeroizing::new(BEARER.to_owned())),
+ access_token: ByoBearer::Set(Zeroizing::new(BEARER.to_owned())),
}),
retention: RetentionPolicy::KeepLatest(NonZeroU64::new(3).expect("nonzero")),
bin_retention_days: DEFAULT_BIN_RETENTION_DAYS,
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 7db1757c38..0eebcd36b4 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -57,13 +57,13 @@ pub use bin_index::{
publish_bin_index,
};
pub use content::{
- ByoIpfsConfig, ByoKind, ContentDag, ContentKey, ContentPlane, ContentProfile, ContentVersion,
- ContentWriter, DAG_ROOT_CODEC, DagError, ExpandError, Expansion, FinishedContent, Gateway,
- GatewayConfig, GatewaySource, PinMode, ProviderError, PrunePlan, QuotaExceeded,
- ROOT_FORMAT_VERSION, ReadError, RetentionPolicy, RetireTarget, RootManifest, SealError,
- SealedChunk, SealedContent, SessionBearer, assemble, decode_root, expand_retire_targets,
- frame_and_seal, leaf_range_for_byte_range, plan_prune, pre_flight_quota_check, read_block,
- seal_one_chunk, test_connection, validate_byo_config,
+ ByoBearer, ByoIpfsConfig, ByoKind, ContentDag, ContentKey, ContentPlane, ContentProfile,
+ ContentVersion, ContentWriter, DAG_ROOT_CODEC, DagError, ExpandError, Expansion,
+ FinishedContent, Gateway, GatewayConfig, GatewaySource, PinMode, ProviderError, PrunePlan,
+ QuotaExceeded, ROOT_FORMAT_VERSION, ReadError, RetentionPolicy, RetireTarget, RootManifest,
+ SealError, SealedChunk, SealedContent, SessionBearer, assemble, decode_root,
+ expand_retire_targets, frame_and_seal, leaf_range_for_byte_range, plan_prune,
+ pre_flight_quota_check, read_block, seal_one_chunk, test_connection, validate_byo_config,
};
pub use deadlines::DeadlinePolicy;
pub use devices::{
diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs
index 7d789930c1..ccadf44acc 100644
--- a/crates/engine/src/settings.rs
+++ b/crates/engine/src/settings.rs
@@ -34,7 +34,9 @@ use zeroize::Zeroizing;
use crate::api::ApiClient;
use crate::content::validate_byo_config;
-use crate::content::{ByoIpfsConfig, ByoKind, Gateway, PinMode, ProviderError, RetentionPolicy};
+use crate::content::{
+ ByoBearer, ByoIpfsConfig, ByoKind, Gateway, PinMode, ProviderError, RetentionPolicy,
+};
use crate::entropy::{Entropy, EntropyError, fresh_ephemeral};
use crate::gate::floor;
use crate::gate::floor::RevisionMintError;
@@ -104,7 +106,7 @@ impl VaultSettings {
byo_credential_stored: self
.byo
.as_ref()
- .is_some_and(|byo| byo.access_token.is_some()),
+ .is_some_and(|byo| byo.access_token.token().is_some()),
retention: self.retention,
bin_retention_days: self.bin_retention_days,
origin,
@@ -112,6 +114,36 @@ impl VaultSettings {
}
}
+/// Settle a save's bearer intent against `held`, the provider config this
+/// session already holds, so [`ByoBearer::Keep`] publishes the stored bearer
+/// rather than the blank the host can never fill.
+///
+/// The keep is bound to the same endpoint and kind, and that binding is the
+/// whole security of the intent: without it a host could carry a credential it
+/// cannot read on to an endpoint it chose, and the next placement would present
+/// the member's bearer there (security rule 3). Keeping with nothing to keep is
+/// refused rather than publishing a credential-free provider.
+pub fn resolve_kept_bearer(
+ settings: &VaultSettings,
+ held: Option<&ByoIpfsConfig>,
+) -> Result {
+ let mut settings = settings.clone();
+ if let Some(byo) = settings.byo.as_mut()
+ && byo.access_token == ByoBearer::Keep
+ {
+ let held = held.ok_or(ProviderError::NoStoredCredential)?;
+ if held.endpoint != byo.endpoint || held.kind != byo.kind {
+ return Err(ProviderError::RepointedCredential);
+ }
+ let kept = held
+ .access_token
+ .token()
+ .ok_or(ProviderError::NoStoredCredential)?;
+ byo.access_token = ByoBearer::Set(kept.clone());
+ }
+ Ok(settings)
+}
+
/// The member's settings as a host may see them: everything but the provider
/// credential, which the wasm boundary exists to keep uncrossable.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -943,7 +975,7 @@ fn encode_settings_body(
"accessToken",
config
.access_token
- .as_ref()
+ .token()
.map_or(Value::Null, |token| Value::Text(token.to_string())),
);
byo.insert("endpoint", Value::Text(config.endpoint.clone()));
@@ -1057,8 +1089,8 @@ fn read_byo(value: Option<&mut Value>) -> Result