From 7448510ef608c9146511d0ba73547b222a7f3aec Mon Sep 17 00:00:00 2001 From: aladi11isah Date: Fri, 16 Oct 2026 05:13:04 +0000 Subject: [PATCH 1/4] fix(ip-registry): reject second notarization instead of silent overwrite (#1148) notarize_ip_timestamp now refuses an IP that already has a notary signature, returning the new AlreadyNotarized error (code 60). The first signature is kept and no overwrite can occur. Closes #1148 --- contracts/ip_registry/src/lib.rs | 17 +++++ .../ip_registry/src/notary_access_tests.rs | 73 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 contracts/ip_registry/src/notary_access_tests.rs diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 3debe86d..1bab6665 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -53,6 +53,9 @@ mod commitment_sybil_resistance_tests; #[cfg(test)] mod validator_network_tests; +#[cfg(test)] +mod notary_access_tests; + // ── Error Codes ──────────────────────────────────────────────────────────── #[contracterror] @@ -159,6 +162,8 @@ pub enum ContractError { InsuranceDisputeMismatch = 58, /// #1073: Dispute must be resolved before the claim can be verified. InsuranceDisputeUnresolved = 59, + /// #1148: The IP already has a notary signature. Notarization is write-once. + AlreadyNotarized = 60, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -4022,14 +4027,26 @@ impl IpRegistry { /// /// # Panics /// + /// Notarization is write-once: a second call for the same IP is rejected + /// rather than silently replacing the first signature (#1148). + /// + /// # Panics + /// /// Panics if: /// * The IP does not exist (IpNotFound error) + /// * The IP is already notarized (AlreadyNotarized error) /// * The notary public key has not been set (Unauthorized error) /// * The signature is not exactly 64 bytes (Unauthorized error) /// * The Ed25519 signature verification fails (Unauthorized error) pub fn notarize_ip_timestamp(env: Env, ip_id: u64, notary_signature: Bytes) { let mut record = require_ip_exists(&env, ip_id); + if record.notary_signature.is_some() { + env.panic_with_error(Error::from_contract_error( + ContractError::AlreadyNotarized as u32, + )); + } + // Require notary public key to be configured let public_key: BytesN<32> = match env.storage().persistent().get(&DataKey::NotaryPublicKey) { diff --git a/contracts/ip_registry/src/notary_access_tests.rs b/contracts/ip_registry/src/notary_access_tests.rs new file mode 100644 index 00000000..c318eaae --- /dev/null +++ b/contracts/ip_registry/src/notary_access_tests.rs @@ -0,0 +1,73 @@ +//! Regression tests for the notary and access-grant hardening issues: +//! - #1148: notarization is write-once +//! - #1149: notary signatures are domain-separated +//! - #1150: invalid access levels return `InvalidAccessLevel` +//! - #1151: access grants can expire and self-grants are rejected + +#[cfg(test)] +mod notary_access_tests { + use ed25519_dalek::{Signer, SigningKey}; + use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env}; + + use crate::{IpRegistry, IpRegistryClient}; + + /// Fixed notary key used by every test that signs a notarization. + fn notary_key() -> SigningKey { + SigningKey::from_bytes(&[42u8; 32]) + } + + /// Register the contract, initialize it and configure the notary key. + /// Returns the contract address and the admin/owner address. + fn setup(env: &Env) -> (Address, Address) { + env.mock_all_auths(); + let contract_id = env.register(IpRegistry, ()); + let owner = Address::generate(env); + let client = IpRegistryClient::new(env, &contract_id); + client.initialize(&owner); + let public_key = BytesN::from_array(env, notary_key().verifying_key().as_bytes()); + client.set_notary_public_key(&public_key); + (contract_id, owner) + } + + /// Sign the legacy notarization message `ip_id || timestamp`. + fn sign_for(env: &Env, _contract: &Address, ip_id: u64, timestamp: u64) -> Bytes { + let mut msg = [0u8; 16]; + msg[..8].copy_from_slice(&ip_id.to_be_bytes()); + msg[8..].copy_from_slice(×tamp.to_be_bytes()); + let sig = notary_key().sign(&msg); + Bytes::from_slice(env, &sig.to_bytes()) + } + + // ── #1148: write-once notarization ─────────────────────────────────────── + + #[test] + #[should_panic(expected = "Error(Contract, #60)")] + fn notarize_twice_is_rejected() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[7u8; 32]), &0u32); + let timestamp = client.get_ip(&ip_id).timestamp; + + let first = sign_for(&env, &contract_id, ip_id, timestamp); + client.notarize_ip_timestamp(&ip_id, &first); + + // A second, valid signature must be refused, not overwrite the first. + let second = sign_for(&env, &contract_id, ip_id, timestamp); + client.notarize_ip_timestamp(&ip_id, &second); + } + + #[test] + fn first_notarization_is_kept() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[8u8; 32]), &0u32); + let timestamp = client.get_ip(&ip_id).timestamp; + + let first = sign_for(&env, &contract_id, ip_id, timestamp); + client.notarize_ip_timestamp(&ip_id, &first); + + assert_eq!(client.get_ip_notary_signature(&ip_id), Some(first)); + } +} From fe4435b830fb83e3175ef5ae2baca0264e9d4f27 Mon Sep 17 00:00:00 2001 From: aladi11isah Date: Fri, 16 Oct 2026 05:13:36 +0000 Subject: [PATCH 2/4] feat(ip-registry): domain-separate notary signatures (#1149) The notary signed only ip_id || timestamp, so a signature issued for one deployment sharing the notary key could be replayed on another. notary_signing_message now prefixes a version byte (1), a fixed domain tag, the network id and the contract address. Legacy untagged signatures no longer verify. The format is documented in docs/notary-signature-format.md. Closes #1149 --- contracts/ip_registry/src/lib.rs | 45 +++++++++++--- .../ip_registry/src/notary_access_tests.rs | 58 ++++++++++++++++++- contracts/ip_registry/src/test.rs | 9 +-- docs/notary-signature-format.md | 57 ++++++++++++++++++ 4 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 docs/notary-signature-format.md diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 1bab6665..115d94c6 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -183,6 +183,33 @@ pub const MAX_METADATA_BYTES: u32 = 1024; /// This is a placeholder - should be set during contract initialization pub const NOTARY_PUBLIC_KEY: &[u8] = b"notary_public_key_placeholder"; +/// Version byte that prefixes every notary signing message (#1149). +/// The original untagged `ip_id || timestamp` format is no longer accepted. +pub const NOTARY_MESSAGE_VERSION: u8 = 1; + +/// Domain-separation tag for notary signing messages (#1149). +pub const NOTARY_DOMAIN_TAG: &[u8] = b"AtomicIP/notary"; + +/// Build the exact bytes a notary signs to notarize `ip_id` at `timestamp` +/// for the deployment at `contract` (#1149). Layout, all concatenated: +/// +/// 1. version byte (`NOTARY_MESSAGE_VERSION`, 1 byte) +/// 2. domain tag (`NOTARY_DOMAIN_TAG`) +/// 3. network id (32 bytes, `Ledger::network_id`) +/// 4. contract address (XDR encoding) +/// 5. `ip_id` (8 bytes, big-endian) +/// 6. `timestamp` (8 bytes, big-endian) +pub fn notary_signing_message(env: &Env, contract: &Address, ip_id: u64, timestamp: u64) -> Bytes { + let mut message = Bytes::new(env); + message.push_back(NOTARY_MESSAGE_VERSION); + message.append(&Bytes::from_slice(env, NOTARY_DOMAIN_TAG)); + message.append(&Bytes::from(env.ledger().network_id())); + message.append(&contract.clone().to_xdr(env)); + message.append(&Bytes::from_array(env, &ip_id.to_be_bytes())); + message.append(&Bytes::from_array(env, ×tamp.to_be_bytes())); + message +} + /// Issue #437: Number of storage shards for commitment distribution. pub const NUM_SHARDS: u32 = 16; @@ -4021,9 +4048,11 @@ impl IpRegistry { /// Notarize an IP timestamp with a notary Ed25519 signature. /// - /// The notary must sign the message `ip_id_be_bytes || timestamp_be_bytes` - /// (8 bytes each, big-endian) with the private key corresponding to the - /// stored notary public key. The 64-byte signature is verified on-chain. + /// The notary must sign the message built by `notary_signing_message` (see + /// `docs/notary-signature-format.md`) with the private key corresponding to + /// the stored notary public key. The 64-byte signature is verified on-chain. + /// The message is bound to this contract's address and network, so a + /// signature issued for one deployment is rejected by any other (#1149). /// /// # Panics /// @@ -4073,10 +4102,12 @@ impl IpRegistry { } }; - // Message: ip_id (8 bytes BE) || timestamp (8 bytes BE) - let mut message = Bytes::new(&env); - message.append(&Bytes::from_array(&env, &ip_id.to_be_bytes())); - message.append(&Bytes::from_array(&env, &record.timestamp.to_be_bytes())); + let message = notary_signing_message( + &env, + &env.current_contract_address(), + ip_id, + record.timestamp, + ); // Verify Ed25519 signature — panics if invalid env.crypto().ed25519_verify(&public_key, &message, &sig); diff --git a/contracts/ip_registry/src/notary_access_tests.rs b/contracts/ip_registry/src/notary_access_tests.rs index c318eaae..79508075 100644 --- a/contracts/ip_registry/src/notary_access_tests.rs +++ b/contracts/ip_registry/src/notary_access_tests.rs @@ -29,8 +29,18 @@ mod notary_access_tests { (contract_id, owner) } - /// Sign the legacy notarization message `ip_id || timestamp`. - fn sign_for(env: &Env, _contract: &Address, ip_id: u64, timestamp: u64) -> Bytes { + /// Sign the current notarization message bound to `contract`. + fn sign_for(env: &Env, contract: &Address, ip_id: u64, timestamp: u64) -> Bytes { + let msg = crate::notary_signing_message(env, contract, ip_id, timestamp); + let mut buf = [0u8; 256]; + let len = msg.len() as usize; + msg.copy_into_slice(&mut buf[..len]); + let sig = notary_key().sign(&buf[..len]); + Bytes::from_slice(env, &sig.to_bytes()) + } + + /// Sign the pre-#1149 untagged message `ip_id || timestamp`. + fn sign_legacy(env: &Env, ip_id: u64, timestamp: u64) -> Bytes { let mut msg = [0u8; 16]; msg[..8].copy_from_slice(&ip_id.to_be_bytes()); msg[8..].copy_from_slice(×tamp.to_be_bytes()); @@ -57,6 +67,50 @@ mod notary_access_tests { client.notarize_ip_timestamp(&ip_id, &second); } + // ── #1149: domain-separated notary signatures ──────────────────────────── + + #[test] + fn notarize_with_current_format_succeeds() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[9u8; 32]), &0u32); + let timestamp = client.get_ip(&ip_id).timestamp; + + let sig = sign_for(&env, &contract_id, ip_id, timestamp); + client.notarize_ip_timestamp(&ip_id, &sig); + assert_eq!(client.get_ip_notary_signature(&ip_id), Some(sig)); + } + + #[test] + #[should_panic] + fn signature_from_another_deployment_is_rejected() { + let env = Env::default(); + // Two deployments share the same notary key. + let (contract_a, owner_a) = setup(&env); + let (contract_b, _owner_b) = setup(&env); + let client_a = IpRegistryClient::new(&env, &contract_a); + let ip_id = client_a.commit_ip(&owner_a, &BytesN::from_array(&env, &[10u8; 32]), &0u32); + let timestamp = client_a.get_ip(&ip_id).timestamp; + + // Signed for deployment B, replayed against deployment A. + let replayed = sign_for(&env, &contract_b, ip_id, timestamp); + client_a.notarize_ip_timestamp(&ip_id, &replayed); + } + + #[test] + #[should_panic] + fn legacy_untagged_signature_is_rejected() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[11u8; 32]), &0u32); + let timestamp = client.get_ip(&ip_id).timestamp; + + let legacy = sign_legacy(&env, ip_id, timestamp); + client.notarize_ip_timestamp(&ip_id, &legacy); + } + #[test] fn first_notarization_is_kept() { let env = Env::default(); diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index e1e84ca8..e127eadb 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -1943,11 +1943,12 @@ mod tests { client.set_notary_public_key(&public_key); let record = client.get_ip(&ip_id); - let mut msg_bytes = [0u8; 16]; - msg_bytes[..8].copy_from_slice(&ip_id.to_be_bytes()); - msg_bytes[8..].copy_from_slice(&record.timestamp.to_be_bytes()); + let msg = crate::notary_signing_message(&env, &contract_id, ip_id, record.timestamp); + let mut msg_bytes = [0u8; 256]; + let msg_len = msg.len() as usize; + msg.copy_into_slice(&mut msg_bytes[..msg_len]); - let sig = signing_key.sign(&msg_bytes); + let sig = signing_key.sign(&msg_bytes[..msg_len]); let notary_sig = soroban_sdk::Bytes::from_slice(&env, &sig.to_bytes()); client.notarize_ip_timestamp(&ip_id, ¬ary_sig); diff --git a/docs/notary-signature-format.md b/docs/notary-signature-format.md new file mode 100644 index 00000000..be8050af --- /dev/null +++ b/docs/notary-signature-format.md @@ -0,0 +1,57 @@ +# Notary Signature Message Format + +Status: **version 1** (current). Introduced by #1149. + +The IP Registry's `notarize_ip_timestamp` verifies an Ed25519 signature made +with the notary key configured by `set_notary_public_key`. The signed message +is built by `notary_signing_message` in `contracts/ip_registry/src/lib.rs`. + +## Why a domain tag + +The earlier format signed only `ip_id || timestamp`. Any notary key shared +between two deployments let a signature issued for one deployment be replayed +on the other. The v1 message binds each signature to one deployment (contract +address) on one network, and to a domain tag that marks it as a notary +signature. + +## Layout + +All fields are concatenated in this order: + +| # | Field | Size | Encoding | +|---|-------|------|----------| +| 1 | Version byte | 1 byte | `0x01` | +| 2 | Domain tag | 15 bytes | ASCII `AtomicIP/notary` | +| 3 | Network id | 32 bytes | `SHA-256(network passphrase)`, as returned by `Ledger::network_id` | +| 4 | Contract address | 40 bytes | XDR encoding of the `Address` (`ScAddress`) of the IP Registry deployment | +| 5 | `ip_id` | 8 bytes | big-endian `u64` | +| 6 | `timestamp` | 8 bytes | big-endian `u64`, the IP record's `timestamp` | + +The signature is a 64-byte Ed25519 signature over the full message. The +contract checks it with `ed25519_verify` against the stored notary public key. + +## Version history + +| Version | Message | Status | +|---------|---------|--------| +| 0 (implicit) | `ip_id \|\| timestamp` (16 bytes, no tag) | **Rejected** since v1 | +| 1 | As in the table above | Current | + +Version 0 signatures never verify under v1. Notaries must re-sign every IP +they want to notarize against a v1 deployment. + +## Rules for implementers + +- Build the message with the same field order and encodings. Do not reuse a + signature across deployments or networks. +- To add a new format, increment the version byte and document the new layout + here. Do not change the layout of an existing version. + +## Test coverage + +`contracts/ip_registry/src/notary_access_tests.rs` covers: + +- a v1 signature from the correct deployment is accepted; +- a v1 signature issued for a different deployment is rejected; +- a legacy (version 0) signature is rejected; +- a second notarization for the same IP is rejected (see #1148). From 414569bc1935b6362bf178a2e0b776b9c12526b9 Mon Sep 17 00:00:00 2001 From: aladi11isah Date: Fri, 16 Oct 2026 05:13:53 +0000 Subject: [PATCH 3/4] fix(ip-registry): return InvalidAccessLevel for out-of-range grant levels (#1150) grant_ip_access reported an authorization failure (Unauthorized) when the access level was outside 1..=3. It now returns the new InvalidAccessLevel error (code 61). revoke_ip_access takes no access level, so it is unchanged. Closes #1150 --- contracts/ip_registry/src/lib.rs | 7 ++-- .../ip_registry/src/notary_access_tests.rs | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 115d94c6..6c189709 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -164,6 +164,8 @@ pub enum ContractError { InsuranceDisputeUnresolved = 59, /// #1148: The IP already has a notary signature. Notarization is write-once. AlreadyNotarized = 60, + /// #1150: Access level is outside the accepted range `1..=3`. + InvalidAccessLevel = 61, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -3898,14 +3900,15 @@ impl IpRegistry { /// /// # Panics /// - /// Panics with `Unauthorized` if `access_level` is 0 or > 3, or if caller is not the owner. + /// Panics with `InvalidAccessLevel` if `access_level` is 0 or > 3 (#1150), + /// or with `Unauthorized` if caller is not the owner. pub fn grant_ip_access(env: Env, ip_id: u64, grantee: Address, access_level: u32) { let record = require_ip_exists(&env, ip_id); record.owner.require_auth(); if !(1..=3).contains(&access_level) { env.panic_with_error(Error::from_contract_error( - ContractError::Unauthorized as u32, + ContractError::InvalidAccessLevel as u32, )); } diff --git a/contracts/ip_registry/src/notary_access_tests.rs b/contracts/ip_registry/src/notary_access_tests.rs index 79508075..3d54b98e 100644 --- a/contracts/ip_registry/src/notary_access_tests.rs +++ b/contracts/ip_registry/src/notary_access_tests.rs @@ -124,4 +124,39 @@ mod notary_access_tests { assert_eq!(client.get_ip_notary_signature(&ip_id), Some(first)); } + + // ── #1150: dedicated error for invalid access levels ───────────────────── + + #[test] + #[should_panic(expected = "Error(Contract, #61)")] + fn grant_level_zero_returns_invalid_access_level() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[12u8; 32]), &0u32); + client.grant_ip_access(&ip_id, &Address::generate(&env), &0u32); + } + + #[test] + #[should_panic(expected = "Error(Contract, #61)")] + fn grant_level_above_three_returns_invalid_access_level() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[13u8; 32]), &0u32); + client.grant_ip_access(&ip_id, &Address::generate(&env), &4u32); + } + + #[test] + fn grant_each_valid_level_is_accepted() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[14u8; 32]), &0u32); + let grantee = Address::generate(&env); + for level in 1u32..=3 { + client.grant_ip_access(&ip_id, &grantee, &level); + assert!(client.check_ip_access(&ip_id, &grantee, &level)); + } + } } From 2d7396701d08b57aaa54291daad68eff11cb966e Mon Sep 17 00:00:00 2001 From: aladi11isah Date: Fri, 16 Oct 2026 05:14:28 +0000 Subject: [PATCH 4/4] feat(ip-registry): optional access-grant expiry and self-grant guard (#1151) IpAccessGrant gains an optional expires_at. grant_ip_access_with_expiry accepts it (an expiry not in the future is rejected with InvalidExpiry), and check_ip_access treats an expired grant as absent. grant_ip_access keeps its signature and stores a non-expiring grant. The owner can no longer grant access to themselves; the call returns the new SelfGrantNotAllowed error (code 62). Closes #1151 --- CHANGELOG.md | 4 + contracts/ip_registry/src/lib.rs | 49 +++++++++++++ .../ip_registry/src/notary_access_tests.rs | 73 +++++++++++++++++++ contracts/ip_registry/src/types.rs | 3 + 4 files changed, 129 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d04a0005..b36fd34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ Each entry below references an issue number from our GitHub repository. When rev - **#66-67** — Error code definitions and security validations - **#781** — Arbitrator committee mechanism with M-of-N signatures and time-locked ruling enforcement - **#906** — Treasury address validation: guard against hardcoded placeholder addresses +- **#1148** — Notarization is write-once: a second `notarize_ip_timestamp` is rejected with `AlreadyNotarized` (code 60) instead of silently replacing the signature +- **#1149** — Notary signatures are domain-separated (version byte, domain tag, network id, contract address); legacy untagged signatures are rejected. See `docs/notary-signature-format.md` +- **#1150** — Out-of-range access levels return `InvalidAccessLevel` (code 61) instead of `Unauthorized` +- **#1151** — Access grants accept an optional `expires_at` (`grant_ip_access_with_expiry`), expired grants are ignored by `check_ip_access`, and self-grants are rejected with `SelfGrantNotAllowed` (code 62) ### Operations & Reliability - **#1066** — Automated backup verification: daily/weekly restore tests, integrity checks, RTO measurement, failure alerts (`scripts/ops/backup-verify.sh`, `docs/backup-strategy.md`) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 6c189709..9b435c72 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -166,6 +166,8 @@ pub enum ContractError { AlreadyNotarized = 60, /// #1150: Access level is outside the accepted range `1..=3`. InvalidAccessLevel = 61, + /// #1151: The owner cannot grant access to their own address. + SelfGrantNotAllowed = 62, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -3903,6 +3905,29 @@ impl IpRegistry { /// Panics with `InvalidAccessLevel` if `access_level` is 0 or > 3 (#1150), /// or with `Unauthorized` if caller is not the owner. pub fn grant_ip_access(env: Env, ip_id: u64, grantee: Address, access_level: u32) { + Self::grant_ip_access_with_expiry(env, ip_id, grantee, access_level, None); + } + + /// Grant access like `grant_ip_access`, with an optional expiry (#1151). + /// + /// `expires_at` is a ledger timestamp. Once `ledger.timestamp() >= expires_at` + /// the grant no longer passes `check_ip_access`. `None` means no expiry. + /// + /// # Panics + /// + /// Panics if: + /// * The IP does not exist (IpNotFound error) + /// * The caller is not the owner (Unauthorized error) + /// * `access_level` is 0 or > 3 (InvalidAccessLevel error) + /// * `grantee` is the owner (SelfGrantNotAllowed error) + /// * `expires_at` is not in the future (InvalidExpiry error) + pub fn grant_ip_access_with_expiry( + env: Env, + ip_id: u64, + grantee: Address, + access_level: u32, + expires_at: Option, + ) { let record = require_ip_exists(&env, ip_id); record.owner.require_auth(); @@ -3912,6 +3937,21 @@ impl IpRegistry { )); } + // #1151: owner already has full access; a self-grant only adds noise. + if grantee == record.owner { + env.panic_with_error(Error::from_contract_error( + ContractError::SelfGrantNotAllowed as u32, + )); + } + + if let Some(expiry) = expires_at { + if expiry <= env.ledger().timestamp() { + env.panic_with_error(Error::from_contract_error( + ContractError::InvalidExpiry as u32, + )); + } + } + let mut grants: Vec = env .storage() .persistent() @@ -3926,6 +3966,7 @@ impl IpRegistry { IpAccessGrant { grantee: grantee.clone(), access_level, + expires_at, }, ); found = true; @@ -3936,6 +3977,7 @@ impl IpRegistry { grants.push_back(IpAccessGrant { grantee: grantee.clone(), access_level, + expires_at, }); } @@ -3995,6 +4037,7 @@ impl IpRegistry { /// a grantee with level 3 satisfies a check for level 1 or 2. /// /// Returns `true` if access is granted, `false` otherwise. + /// Expired grants (#1151) are treated as absent. pub fn check_ip_access(env: Env, ip_id: u64, grantee: Address, required_level: u32) -> bool { let record = require_ip_exists(&env, ip_id); // Owner always has full access @@ -4006,8 +4049,14 @@ impl IpRegistry { .persistent() .get(&DataKey::IpAccessGrants(ip_id)) .unwrap_or(Vec::new(&env)); + let now = env.ledger().timestamp(); for grant in grants.iter() { if grant.grantee == grantee { + if let Some(expiry) = grant.expires_at { + if now >= expiry { + return false; + } + } return grant.access_level >= required_level; } } diff --git a/contracts/ip_registry/src/notary_access_tests.rs b/contracts/ip_registry/src/notary_access_tests.rs index 3d54b98e..e4793b6e 100644 --- a/contracts/ip_registry/src/notary_access_tests.rs +++ b/contracts/ip_registry/src/notary_access_tests.rs @@ -159,4 +159,77 @@ mod notary_access_tests { assert!(client.check_ip_access(&ip_id, &grantee, &level)); } } + + // ── #1151: grant expiry and self-grant guard ───────────────────────────── + + #[test] + #[should_panic(expected = "Error(Contract, #62)")] + fn owner_cannot_grant_to_self() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[15u8; 32]), &0u32); + client.grant_ip_access(&ip_id, &owner, &1u32); + } + + #[test] + #[should_panic(expected = "Error(Contract, #11)")] + fn expiry_in_the_past_is_rejected() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[16u8; 32]), &0u32); + env.ledger().set_timestamp(1_000); + client.grant_ip_access_with_expiry( + &ip_id, + &Address::generate(&env), + &1u32, + &Some(500u64), + ); + } + + #[test] + fn expired_grant_is_not_honoured() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[17u8; 32]), &0u32); + let grantee = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + client.grant_ip_access_with_expiry(&ip_id, &grantee, &3u32, &Some(2_000u64)); + assert!(client.check_ip_access(&ip_id, &grantee, &3u32)); + + // Boundary: at exactly `expires_at` the grant is already expired. + env.ledger().set_timestamp(2_000); + assert!(!client.check_ip_access(&ip_id, &grantee, &1u32)); + } + + #[test] + fn grant_without_expiry_never_lapses() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[18u8; 32]), &0u32); + let grantee = Address::generate(&env); + + client.grant_ip_access(&ip_id, &grantee, &2u32); + env.ledger().set_timestamp(u32::MAX as u64 * 10); + assert!(client.check_ip_access(&ip_id, &grantee, &2u32)); + } + + #[test] + fn stored_grant_records_its_expiry() { + let env = Env::default(); + let (contract_id, owner) = setup(&env); + let client = IpRegistryClient::new(&env, &contract_id); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[19u8; 32]), &0u32); + let grantee = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + client.grant_ip_access_with_expiry(&ip_id, &grantee, &1u32, &Some(5_000u64)); + let grants = client.get_ip_access_grants(&ip_id); + assert_eq!(grants.get(0).unwrap().expires_at, Some(5_000u64)); + } } + diff --git a/contracts/ip_registry/src/types.rs b/contracts/ip_registry/src/types.rs index 33c36f61..19b09c37 100644 --- a/contracts/ip_registry/src/types.rs +++ b/contracts/ip_registry/src/types.rs @@ -30,6 +30,9 @@ pub const ACCESS_TRANSFER: u32 = 3; // Can initiate transfer (view + verify + tr pub struct IpAccessGrant { pub grantee: Address, pub access_level: u32, // 1 = view, 2 = verify, 3 = transfer + /// Ledger timestamp at which the grant stops being valid (#1151). + /// `None` means the grant never expires. + pub expires_at: Option, } /// Cached proof path tied to the owner's current Merkle root.