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
26 changes: 20 additions & 6 deletions apps/web/src/components/settings/VaultSettingsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/components/settings/VaultSettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<VaultSettingsFields>(DEFAULT_VAULT_SETTINGS_FORM);
Expand Down Expand Up @@ -85,23 +90,28 @@ 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,
});
if (!verdict.ok) {
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(
(accepted) => {
// 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.
Expand Down Expand Up @@ -228,7 +238,7 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
: '// the engine never reads a provider credential back out,'}
<br />
{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.'}
</p>
)}
Expand Down Expand Up @@ -270,8 +280,7 @@ export function VaultSettingsForm({ summary, onSaved }: VaultSettingsFormProps)
onChange={(event) => setAcknowledged(event.target.checked)}
/>
<span>
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
</span>
</label>

Expand Down
26 changes: 23 additions & 3 deletions apps/web/src/settings/vaultSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { KEEP_STORED_BEARER } from '@cipherbox/client';
import {
buildVaultSettings,
DEFAULT_VAULT_SETTINGS_FORM,
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/settings/vaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* what an empty one means.
*/

import { KEEP_STORED_BEARER } from '@cipherbox/client';
import type { ByoKind, PinMode, VaultSettingsDescriptor } from '@cipherbox/client';

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
},
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/content/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
87 changes: 74 additions & 13 deletions crates/engine/src/content/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>),
/// 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<String>> {
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(<redacted>)",
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
Expand All @@ -77,19 +114,16 @@ 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<Zeroizing<String>>,
/// Kubo when fronted by an auth proxy).
pub access_token: ByoBearer,
}

impl fmt::Debug for ByoIpfsConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ByoIpfsConfig")
.field("endpoint", &self.endpoint)
.field("kind", &self.kind)
.field(
"access_token",
&self.access_token.as_ref().map(|_| "<redacted>"),
)
.field("access_token", &self.access_token)
.finish()
}
}
Expand Down Expand Up @@ -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<String>) -> 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()),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -354,6 +403,9 @@ impl ProviderError {
| ProviderError::InsecureTransport
| ProviderError::BlockedAddress
| ProviderError::InvalidCredential
| ProviderError::UnresolvedCredential
| ProviderError::NoStoredCredential
| ProviderError::RepointedCredential
)
}
}
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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(
Expand Down
Loading