Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cce6e7a
Admit held email credential presentations behind a default-off flag
Oct 20, 2026
60ad477
Regenerate separate workspace lockfiles for node-server dependencies
Oct 20, 2026
f0c4844
Enforce held claims, expiry cap and maxAgeSeconds pin
Oct 20, 2026
59f16b6
Harden held email verifier against beta.31 reference rules
Oct 21, 2026
0b86665
Expire held nonces and refuse malformed owner as client error
Oct 21, 2026
25fdd10
Match beta.31 held-presentation refusals; commit TS differential table
Oct 21, 2026
647a068
Refuse seconds above 59 in held RFC 3339; commit reproducible differe…
Oct 21, 2026
9f3eb78
Cover held admission gates, handler order, and strict Ed25519 (TC-145…
Oct 21, 2026
2fb689d
Refuse undisclosed claims, pin held route gate, and use spec issuer (…
Oct 21, 2026
235ec77
Merge remote-tracking branch 'origin/main' into skgbafa/tc-1452-held-…
Oct 21, 2026
5c5fbac
Wrap mint_held_presentation signature for rustfmt (TC-1452)
Oct 21, 2026
1e9171b
Regenerate S1 differential from js-sdk #645 with literal email issuer
Oct 21, 2026
143bad7
test(held): pin admission refusals for requirement, max-age, kid, cas…
Oct 21, 2026
4c40dc9
test(held): pin v3 issuer signature to verify_strict
Oct 21, 2026
253ca13
test(held): pin presentation-capabilities-mismatch label for non-arra…
Oct 21, 2026
c2cdf7c
test(held): pin v3 presentation capabilities and binding refusals
Oct 21, 2026
78bd705
fix(held): match TS audience origin, did:pkh casing, and SIWE millise…
Oct 21, 2026
b2e8417
test(held): regenerate S1 differential from published sdk-core beta.37
Oct 21, 2026
71035f3
docs(changelog): note held credential admission under unreleased
Oct 21, 2026
36bc627
test(held): pin high-S SIWE refusal; correct recovery comment
Oct 21, 2026
0092ee2
test(held): build high-S fixture without deprecated GenericArray calls
Oct 21, 2026
cf100fc
test(held): pin credential-field shape refusal on the flag-on route
Oct 21, 2026
5752aa1
Rebuild TS differential table to 333 cases with explicit deviations
Oct 21, 2026
c93a3ac
Point high-S comments at the recorded differential deviation
Oct 21, 2026
f7936e0
Let held test harness pin explicit credential issuer and custom proje…
Oct 21, 2026
76c55cd
Add real-time held mint fixture and mint-path tests
Oct 21, 2026
414b048
Add non-canonical S at group order refusal test for issuer signatures
Oct 21, 2026
93100bf
test(held): pin disclosed vct on the held route and the signed-creden…
Oct 21, 2026
922f02f
held: index held_credential_nonce.expires_at; pin caps and v3 S+L
Oct 21, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Changes

- Held email credential admission is available behind `[held_credentials]`, off by default. With the flag off, a held presentation is refused before any verifier work. Adds the `held_credential_nonce` table (migration `m20261105_000000_held_credential_nonce`). The migration is forward-only once it has run.

## [1.21.6] - 2026-10-21

### Release notes
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/m1-realdata-e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/w5-policy-runtime-node-e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! TC-1452: `held_credential_nonce` table. A presentation for a held credential
//! spends its nonce by deleting the row whose audience, nonce and account all
//! match, so a nonce is single use and bound to the account it was minted for.
//! Every consume first prunes rows with `expires_at <= now`, so `expires_at` is
//! indexed to keep that prune from scanning the table.

use sea_orm_migration::prelude::*;

use crate::models::held_credential_nonce;

const EXPIRES_AT_INDEX: &str = "idx_held_credential_nonce_expires_at";

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(held_credential_nonce::Entity)
.if_not_exists()
.col(
ColumnDef::new(held_credential_nonce::Column::Audience)
.string()
.not_null(),
)
.col(
ColumnDef::new(held_credential_nonce::Column::Nonce)
.string()
.not_null(),
)
.col(
ColumnDef::new(held_credential_nonce::Column::Account)
.string()
.not_null(),
)
.col(
ColumnDef::new(held_credential_nonce::Column::ExpiresAt)
.big_integer()
.not_null(),
)
.primary_key(
Index::create()
.col(held_credential_nonce::Column::Audience)
.col(held_credential_nonce::Column::Nonce),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name(EXPIRES_AT_INDEX)
.table(held_credential_nonce::Entity)
.col(held_credential_nonce::Column::ExpiresAt)
.if_not_exists()
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name(EXPIRES_AT_INDEX)
.table(held_credential_nonce::Entity)
.if_exists()
.to_owned(),
)
.await?;
manager
.drop_table(
Table::drop()
.table(held_credential_nonce::Entity)
.if_exists()
.to_owned(),
)
.await
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::PostgresTestSchema;
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement};

/// Columns of the expires_at index on SQLite, in key order. Empty when absent.
async fn sqlite_index_columns(db: &DatabaseConnection) -> Vec<String> {
db.query_all(Statement::from_string(
DbBackend::Sqlite,
format!("PRAGMA index_info('{EXPIRES_AT_INDEX}')"),
))
.await
.unwrap()
.iter()
.map(|row| row.try_get("", "name").unwrap())
.collect()
}

async fn sqlite_table_exists(db: &DatabaseConnection) -> bool {
db.query_one(Statement::from_string(
DbBackend::Sqlite,
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'held_credential_nonce'"
.to_string(),
))
.await
.unwrap()
.is_some()
}

/// Definition of the expires_at index on Postgres, or `None` when absent.
async fn postgres_index_def(db: &DatabaseConnection) -> Option<String> {
db.query_one(Statement::from_string(
DbBackend::Postgres,
format!(
"SELECT indexdef FROM pg_indexes \
WHERE schemaname = current_schema() AND indexname = '{EXPIRES_AT_INDEX}'"
),
))
.await
.unwrap()
.map(|row| row.try_get("", "indexdef").unwrap())
}

async fn postgres_table_exists(db: &DatabaseConnection) -> bool {
db.query_one(Statement::from_string(
DbBackend::Postgres,
"SELECT to_regclass('held_credential_nonce') IS NOT NULL AS present".to_string(),
))
.await
.unwrap()
.unwrap()
.try_get("", "present")
.unwrap()
}

#[tokio::test]
async fn sqlite_expires_at_index_follows_up_and_down() {
let directory = tempfile::tempdir().unwrap();
let url = format!(
"sqlite://{}?mode=rwc",
directory.path().join("nonce.db").display()
);
let db = Database::connect(url).await.unwrap();
let manager = SchemaManager::new(&db);

Migration.up(&manager).await.unwrap();
assert_eq!(sqlite_index_columns(&db).await, ["expires_at"]);

Migration.down(&manager).await.unwrap();
assert!(sqlite_index_columns(&db).await.is_empty());
assert!(!sqlite_table_exists(&db).await);

Migration.up(&manager).await.unwrap();
assert_eq!(sqlite_index_columns(&db).await, ["expires_at"]);
}

#[tokio::test]
async fn postgres_expires_at_index_follows_up_and_down() {
// The state a node upgrading to this migration boots with: this migration
// and everything after it still unapplied.
let this = crate::migrations::Migrator::migrations()
.iter()
.position(|migration| migration.name() == Migration.name())
.unwrap() as u32;
let Some(fixture) = PostgresTestSchema::migrated_through(
"postgres_expires_at_index_follows_up_and_down",
Some(this),
)
.await
else {
return;
};
let db = &fixture.conn;
let manager = SchemaManager::new(db);
assert!(postgres_index_def(db).await.is_none());

Migration.up(&manager).await.unwrap();
let def = postgres_index_def(db).await.expect("index created by up");
assert!(def.ends_with("(expires_at)"), "{def}");

Migration.down(&manager).await.unwrap();
assert!(postgres_index_def(db).await.is_none());
assert!(!postgres_table_exists(db).await);

Migration.up(&manager).await.unwrap();
let def = postgres_index_def(db).await.expect("index recreated by up");
assert!(def.ends_with("(expires_at)"), "{def}");
}
}
2 changes: 2 additions & 0 deletions tinycloud-core/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod m20261015_000000_delegation_cursor_index;
pub mod m20261017_000000_epoch_order_space_index;
pub mod m20261018_000000_epoch_head;
pub mod m20261020_000000_current_kv_key_c_index;
pub mod m20261105_000000_held_credential_nonce;

pub struct Migrator;

Expand Down Expand Up @@ -66,6 +67,7 @@ impl MigratorTrait for Migrator {
Box::new(m20261017_000000_epoch_order_space_index::Migration),
Box::new(m20261018_000000_epoch_head::Migration),
Box::new(m20261020_000000_current_kv_key_c_index::Migration),
Box::new(m20261105_000000_held_credential_nonce::Migration),
]
}
}
Expand Down
21 changes: 21 additions & 0 deletions tinycloud-core/src/models/held_credential_nonce.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use sea_orm::entity::prelude::*;

/// A nonce minted for held-credential presentation, bound to one audience and
/// one canonical `did:pkh:eip155` account. A row is spent by deleting it, so
/// the table holds only unspent nonces.
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "held_credential_nonce")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub audience: String,
#[sea_orm(primary_key, auto_increment = false)]
pub nonce: String,
pub account: String,
/// Unix seconds after which the nonce can no longer be spent.
pub expires_at: i64,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
1 change: 1 addition & 0 deletions tinycloud-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod encryption_network_member;
pub mod encryption_nonce;
pub mod epoch;
pub mod epoch_head;
pub mod held_credential_nonce;
pub mod hook_delivery;
pub mod hook_subscription;
pub mod invocation;
Expand Down
8 changes: 6 additions & 2 deletions tinycloud-node-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ path = "src/main.rs"
anyhow = "1"
# Canonical-point check for pinned issuer keys (issuer_set.rs).
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
k256 = { version = "0.13.4", features = ["ecdsa"] }
sha3 = "0.10"
# WHATWG URL parsing for the SIWE origin check (held_email.rs).
url = "2"
aes-gcm = "0.10"
async-trait.workspace = true
# No default HTTPS client: the SDK's default pulls in rustls with aws-lc-rs,
Expand Down Expand Up @@ -118,6 +122,8 @@ default-features = false
features = ["sqlite", "postgres", "mysql", "tokio", "verification-count-test-hook"]

[dev-dependencies]
# Builds the small-order-R forgery vector in held_email tests (TC-1541 item 2); already in the lock.
curve25519-dalek = "4"
# Parse production Rust items in the DID resolver source guard (tests only).
syn = { version = "2", features = ["full", "visit"] }
quote = "1"
Expand All @@ -133,8 +139,6 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd
# zlib compressibility of raw stored bytes in tests/at_rest_harness.rs (TC-887).
# Already in the lock through ssi-status; adds no new crate.
flate2 = "1"
k256 = { version = "0.13.4", features = ["ecdsa"] }
sha3 = "0.10"
# The Rust half of the JS SDK: builds the same host and session SIWE
# delegations a real `signIn` sends (tests/postgres_round_trip_budget.rs).
tinycloud-sdk-wasm = { path = "../tinycloud-sdk-wasm" }
Expand Down
13 changes: 13 additions & 0 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ pub struct Config {
pub public_spaces: PublicSpacesConfig,
#[serde(default)]
pub share_email: ShareEmailConfig,
/// TC-1452: admission of held email credential presentations. Default OFF;
/// inert until enabled.
#[serde(default)]
pub held_credentials: HeldCredentialsConfig,
#[serde(default)]
pub retention: RetentionConfig,
#[serde(default)]
Expand Down Expand Up @@ -181,6 +185,15 @@ fn default_share_clock_skew() -> i64 {
fn default_share_challenge_ttl() -> u64 {
120
}
/// TC-1452: held email credential admission. Inert unless `enabled`.
#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)]
#[serde(default)]
pub struct HeldCredentialsConfig {
/// Default OFF. When off, held presentations are refused before any work.
pub enabled: bool,
/// The relying-party audience a SIWE message must name. Required when enabled.
pub audience: Option<String>,
}
fn default_share_space_name() -> String {
"default".to_owned()
}
Expand Down
Loading
Loading