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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- **R-15 Gate 2 closure — record-class disposition (#445):** every protected record class now has
exactly one recorded disposition from the storage contract, and the record codec only seals and
opens classes that are meant to become protected records. API credentials and the browser
storage key-derivation salt and passphrase check keep their own separate protection and are
refused, so they never end up in an ordinary protected record; a class without a recorded
disposition is refused too. Nothing in the app uses this yet, and the production authority
switch stays off. PR #929.
- **R-15 Gate 2, slice A — identity-bound record codec (#445):** a protected record can now be
sealed and opened through its typed identity, so its authenticated data always comes from the
contract identity rather than hand-assembled fields. Moving encrypted data to another record,
Expand Down
120 changes: 120 additions & 0 deletions crates/worldscript-secure-storage/src/disposition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Gate 2 closure: which record classes may exist as R-15 envelopes at all (§10.4.1).
//!
//! §10.4.1 is an exhaustive registry: every class has exactly one disposition, and none defaults to
//! `MIGRATE_TO_R15` merely because it is not listed elsewhere. This module mirrors it with explicit
//! lists, so a class missing from all of them has no admitted disposition and the record codec
//! refuses it, the contract's `REFUSE_AUTHORITY_SWITCH` default, rather than sealing it silently.

use crate::record_class::RecordClass;

/// How a record class relates to R-15 envelopes (§10.4.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
/// `MIGRATE_TO_R15`: an ordinary protected record whose destination is an R-15 envelope.
MigrateToR15,
/// R-15's own control-plane records (§5.3, §5.4, §10.1), created natively under the target
/// epoch; no migration disposition applies, but they are R-15 envelopes.
NativeControlPlane,
/// `RETAIN_APPROVED_SEPARATE_PROTECTED_AUTHORITY`: the class keeps its own approved protection
/// mechanism, and no R-15 ciphertext is ever created for it.
RetainSeparateAuthority,
}

use Disposition::{MigrateToR15, NativeControlPlane, RetainSeparateAuthority};

/// §10.4.1's registry, one row per version-1 class token.
#[rustfmt::skip]
const DISPOSITIONS: &[(RecordClass, Disposition)] = &[
// MIGRATE_TO_R15: §10.4.1's 28 class rows as 31 tokens. The plot-board/mind-map row covers two
// tokens and the "LoRA adapters, datasets and run metadata" row three; every other row is one.
(RecordClass::Project, MigrateToR15),
(RecordClass::ProjectMetadata, MigrateToR15),
(RecordClass::Snapshot, MigrateToR15),
(RecordClass::Backup, MigrateToR15),
(RecordClass::Recovery, MigrateToR15),
(RecordClass::Settings, MigrateToR15),
(RecordClass::Image, MigrateToR15),
(RecordClass::Asset, MigrateToR15),
(RecordClass::AssetMetadata, MigrateToR15),
(RecordClass::Codex, MigrateToR15),
(RecordClass::RagIndex, MigrateToR15),
(RecordClass::WorkerDlq, MigrateToR15),
(RecordClass::ActiveProject, MigrateToR15),
(RecordClass::Diagnostic, MigrateToR15),
(RecordClass::LocalFirstDoc, MigrateToR15),
(RecordClass::AnalyticsDb, MigrateToR15),
(RecordClass::CrossProjectIndex, MigrateToR15),
(RecordClass::SceneComments, MigrateToR15),
(RecordClass::SceneRevision, MigrateToR15),
(RecordClass::PlotUi, MigrateToR15),
(RecordClass::MindMapUi, MigrateToR15),
(RecordClass::Progress, MigrateToR15),
(RecordClass::ProforgeMemory, MigrateToR15),
(RecordClass::ProforgeHistory, MigrateToR15),
(RecordClass::InferenceCache, MigrateToR15),
(RecordClass::Lora, MigrateToR15),
(RecordClass::LoraDataset, MigrateToR15),
(RecordClass::LoraRun, MigrateToR15),
(RecordClass::LoraMirror, MigrateToR15),
(RecordClass::Telemetry, MigrateToR15),
(RecordClass::AiBenchmark, MigrateToR15),
// Native control plane: §10.4.1's 5 class rows as 7 tokens. The manifest-and-catalog,
// commit-marker (`record-commit` plus the `asset-pair` marker) and migration-journal rows cover
// two tokens each, key epochs one, and migration staging none (it is never a record identity).
(RecordClass::AuthorityRoot, NativeControlPlane),
(RecordClass::RecordCatalog, NativeControlPlane),
(RecordClass::KeyEpoch, NativeControlPlane),
(RecordClass::RecordCommit, NativeControlPlane),
(RecordClass::AssetPair, NativeControlPlane),
(RecordClass::Migration, NativeControlPlane),
(RecordClass::MigrationPage, NativeControlPlane),
// RETAIN_APPROVED_SEPARATE_PROTECTED_AUTHORITY.
(RecordClass::Credential, RetainSeparateAuthority),
(RecordClass::IdbKdfSalt, RetainSeparateAuthority),
(RecordClass::IdbPassphraseSentinel, RetainSeparateAuthority),
];

/// The §10.4.1 disposition of `class`, or `None` when it has none admitted.
pub fn disposition(class: RecordClass) -> Option<Disposition> {
DISPOSITIONS
.iter()
.find(|(registered, _)| *registered == class)
.map(|(_, disposition)| *disposition)
}

/// Whether records of `class` may be sealed or opened as R-15 envelopes: only `MIGRATE_TO_R15` and
/// native control-plane classes. A retained separate authority, or a class without an admitted
/// disposition, never yields R-15 ciphertext.
pub fn is_r15_record_class(class: RecordClass) -> bool {
matches!(disposition(class), Some(MigrateToR15 | NativeControlPlane))
Comment thread
qnbs marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn every_class_has_exactly_one_disposition() {
for class in RecordClass::ALL {
let rows = DISPOSITIONS
.iter()
.filter(|(registered, _)| registered == class)
.count();
assert_eq!(rows, 1, "{class:?}");
}
assert_eq!(DISPOSITIONS.len(), RecordClass::ALL.len());
}

#[test]
fn the_registry_matches_the_contract_counts() {
let count = |wanted: Disposition| {
DISPOSITIONS
.iter()
.filter(|(_, disposition)| *disposition == wanted)
.count()
};
assert_eq!(count(MigrateToR15), 31);
assert_eq!(count(NativeControlPlane), 7);
assert_eq!(count(RetainSeparateAuthority), 3);
}
}
6 changes: 6 additions & 0 deletions crates/worldscript-secure-storage/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub enum SealError {
/// §6.4/§7: the record schema is not in the version-1 compatibility registry, so no current
/// reader could decode the record.
UnsupportedSchema,
/// §10.4.1: the record's class never becomes an R-15 envelope (it keeps a separate approved
/// protected authority, or has no admitted disposition).
NotAnR15RecordClass,
}

/// Semantic open/parse failures, mapped from §7. Key-resolution outcomes (locked, wrong key) belong
Expand All @@ -36,6 +39,9 @@ pub enum OpenError {
/// AEAD authentication failed with the supplied key and context (§7 `PROTECTED_TAMPERED`).
Tampered,
InvalidContext(AadError),
/// §10.4.1: the requested class never has R-15 ciphertext (it keeps a separate approved
/// protected authority, or has no admitted disposition), so nothing is parsed or decrypted.
NotAnR15RecordClass,
}

/// Why the native recovery KDF refused to derive (§8.2.1).
Expand Down
23 changes: 9 additions & 14 deletions crates/worldscript-secure-storage/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use crate::aad::RecordContext;
use crate::anchor::MAX_OPERATION_ID_LEN;
use crate::disposition::{disposition, Disposition};
use crate::provider::InstallationScopeId;
use crate::record_class::RecordClass;

Expand Down Expand Up @@ -48,8 +49,9 @@ pub enum IdentityError {
/// `record-commit` identities are built only from another identity, never directly.
NotBuildableDirectly,
/// The record is not governed by an ordinary `record-commit` marker: it is a marker itself
/// (`record-commit`, `asset-pair`), a member committed by its `asset-pair` marker (§8.4), or a
/// control record anchored by the authority root (§5.3, §10.1).
/// (`record-commit`, `asset-pair`), a member committed by its `asset-pair` marker (§8.4), a
/// control record anchored by the authority root (§5.3, §10.1), or a class that keeps a
/// separate approved authority and never becomes an R-15 record (§10.4.1).
NoOrdinaryMarker,
}

Expand Down Expand Up @@ -295,18 +297,11 @@ fn has_pair_relation(class: RecordClass) -> bool {

/// Whether `class` is committed through its own `record-commit` marker.
fn has_ordinary_marker(class: RecordClass) -> bool {
!matches!(
class,
RecordClass::RecordCommit
| RecordClass::AssetPair
| RecordClass::Asset
| RecordClass::AssetMetadata
| RecordClass::AuthorityRoot
| RecordClass::KeyEpoch
| RecordClass::RecordCatalog
| RecordClass::Migration
| RecordClass::MigrationPage
)
// Only `MIGRATE_TO_R15` records (§10.4.1) are ordinary records, and an asset-pair member is
// committed by its pair marker instead (§8.4). Control-plane records are anchored by the
// authority root, and retained-authority classes have no R-15 record to commit at all.
disposition(class) == Some(Disposition::MigrateToR15)
&& !matches!(class, RecordClass::Asset | RecordClass::AssetMetadata)
}

fn check_component(part: Part, value: &str) -> Result<(), IdentityError> {
Expand Down
8 changes: 5 additions & 3 deletions crates/worldscript-secure-storage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
//!
//! Headless only: the `WSR1` envelope header (§6.1), the record-class registry (§6.1.1), canonical
//! AAD (§6.2), and AES-256-GCM seal/open with an OS-backed nonce source (§6.3), plus the Gate 2
//! slice 1 typed record-identity registry ([`identity`], §5.2) and the identity-bound record codec
//! ([`record`]). It changes no current TypeScript/Tauri storage authority and holds no key
//! provider, journal, or durable I/O.
//! slice 1 typed record-identity registry ([`identity`], §5.2), the identity-bound record codec
//! ([`record`]) and the §10.4.1 record-class disposition ([`mod@disposition`]). It changes no
//! current TypeScript/Tauri storage authority and holds no key provider, journal, or durable I/O.

pub mod aad;
pub mod anchor;
pub mod anchor_codec;
pub mod disposition;
pub mod envelope;
pub mod error;
pub mod identity;
Expand All @@ -28,6 +29,7 @@ pub mod store_layout;
pub mod store_runtime;

pub use aad::{canonical_aad, RecordContext};
pub use disposition::{disposition, is_r15_record_class, Disposition};
pub use envelope::{parse_envelope, EnvelopeHeader, ParsedEnvelope};
pub use error::{AadError, KdfError, KeyProviderError, OpenError, RecoveryError, SealError};
pub use identity::{IdentityError, RecordIdentity};
Expand Down
10 changes: 8 additions & 2 deletions crates/worldscript-secure-storage/src/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! `PROTECTED_TAMPERED` (§7) instead of opening under the wrong identity. No I/O happens here: where
//! the bytes live, and whether they are the newest committed generation, belongs to later gates.

use crate::disposition::is_r15_record_class;
use crate::envelope::{parse_envelope, EnvelopeHeader};
use crate::error::{OpenError, SealError};
use crate::identity::RecordIdentity;
Expand Down Expand Up @@ -41,7 +42,9 @@ impl std::fmt::Debug for OpenedRecord {

/// Seals `plaintext` as one version of the record `identity` names: a complete `WSR1` envelope whose
/// AAD is that identity's canonical context (§6.2). A record schema outside the compatibility
/// registry is refused, so no record is written that current readers cannot decode.
/// registry is refused, so no record is written that current readers cannot decode, and so is a
/// class whose §10.4.1 disposition never yields R-15 ciphertext (credentials, the IDB KDF salt and
/// passphrase sentinel); the raw [`seal`] enforces the same disposition rule.
pub fn seal_record(
key: &Key,
identity: &RecordIdentity,
Expand All @@ -62,12 +65,15 @@ pub fn seal_record(
/// `identity` names. Malformed or future-format bytes, including a record schema outside the
/// compatibility registry (§7 `PROTECTED_UNSUPPORTED_VERSION`), are refused before any decryption,
/// so no payload reaches a decoder that cannot read it; ciphertext sealed under any other identity is
/// `Tampered`.
/// `Tampered`. A class that never has R-15 ciphertext (§10.4.1) is refused before any parsing.
pub fn open_record(
key: &Key,
identity: &RecordIdentity,
bytes: &[u8],
) -> Result<OpenedRecord, OpenError> {
if !is_r15_record_class(identity.class()) {
return Err(OpenError::NotAnR15RecordClass);
}
let envelope = parse_envelope(bytes)?;
if !admitted_schema(envelope.header().record_schema) {
return Err(OpenError::UnsupportedVersion("record schema"));
Expand Down
9 changes: 9 additions & 0 deletions crates/worldscript-secure-storage/src/seal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use aes_gcm::{Aes256Gcm, Nonce};
use zeroize::{Zeroize, ZeroizeOnDrop};

use crate::aad::{canonical_aad, RecordContext};
use crate::disposition::is_r15_record_class;
use crate::envelope::{EnvelopeHeader, ParsedEnvelope, MAX_CIPHERTEXT_LEN, NONCE_LEN, TAG_LEN};
use crate::error::{OpenError, SealError};
use crate::random::{OsRandom, RandomSource};
Expand Down Expand Up @@ -78,6 +79,11 @@ fn seal_inner(
plaintext: &[u8],
) -> Result<Vec<u8>, SealError> {
let SealTarget { context, meta } = *target;
// §10.4.1: a class that keeps a separate approved authority (or has no admitted disposition)
// never yields R-15 ciphertext, whichever entry point is used.
if !is_r15_record_class(context.record_class) {
return Err(SealError::NotAnR15RecordClass);
}
check_counters(&meta)?;
let ciphertext_len = (plaintext.len() as u64)
.checked_add(TAG_LEN as u64)
Expand Down Expand Up @@ -126,6 +132,9 @@ pub fn open(
context: &RecordContext<'_>,
envelope: &ParsedEnvelope<'_>,
) -> Result<Vec<u8>, OpenError> {
if !is_r15_record_class(context.record_class) {
return Err(OpenError::NotAnR15RecordClass);
}
let aad = canonical_aad(context, envelope.header_bytes()).map_err(OpenError::InvalidContext)?;
cipher(key)
.decrypt(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! identity never opens under another.

use worldscript_secure_storage::{
canonical_aad, open, parse_envelope, seal, EnvelopeHeader, IdentityError, Key, OpenError,
RecordClass, RecordIdentity, RecordMeta, SealTarget,
canonical_aad, is_r15_record_class, open, parse_envelope, seal, EnvelopeHeader, IdentityError,
Key, OpenError, RecordClass, RecordIdentity, RecordMeta, SealTarget,
};

const SCOPE: &str = "0123456789abcdef0123456789abcdef";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ fn commit_markers_embed_the_class_qualified_identity_and_inherit_its_scope() {
}

#[test]
fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() {
fn markers_pair_members_control_and_retained_records_have_no_ordinary_marker() {
let refused = [
RecordIdentity::commit_marker(&identity(RecordClass::Codex, &["p1"])).unwrap(),
identity(RecordClass::AssetPair, &["p1", "a1"]),
Expand All @@ -98,6 +98,10 @@ fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() {
identity(RecordClass::RecordCatalog, &[SCOPE, "0"]),
identity(RecordClass::Migration, &["op1"]),
identity(RecordClass::MigrationPage, &["op1", "0"]),
// Retained separate authorities never become R-15 records (§10.4.1).
identity(RecordClass::Credential, &["openai"]),
identity(RecordClass::IdbKdfSalt, &[SCOPE]),
identity(RecordClass::IdbPassphraseSentinel, &[SCOPE]),
];
for record in &refused {
assert_eq!(
Expand All @@ -106,13 +110,13 @@ fn markers_asset_pair_members_and_control_records_have_no_ordinary_marker() {
"{record:?}"
);
}
// Every other registered class has exactly one marker.
// Every other registered class (`MIGRATE_TO_R15` except the two pair members) has one marker.
let ordinary = registry()
.into_iter()
.map(|(class, components, _, _)| identity(class, &components))
.filter(|record| RecordIdentity::commit_marker(record).is_ok())
.count();
assert_eq!(ordinary, 40 - 8);
assert_eq!(ordinary, 40 - 8 - 3);
Comment thread
qnbs marked this conversation as resolved.
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,11 @@ fn identical_identities_produce_identical_aad() {

#[test]
fn ciphertext_sealed_under_one_identity_never_opens_under_another() {
// All registered identities, plus the commit marker of each, are pairwise distinct AAD contexts.
// All registered R-15 identities, plus the commit marker of each, are pairwise distinct AAD
// contexts. Retained-authority classes have no R-15 ciphertext at all (§10.4.1).
let mut identities: Vec<RecordIdentity> = registry()
.into_iter()
.filter(|(class, _, _, _)| is_r15_record_class(*class))
.map(|(class, components, _, _)| identity(class, &components))
.collect();
let markers: Vec<RecordIdentity> = identities
Expand Down
Loading
Loading