lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 4ec115296b1667331a3a8774baf313c33917867a
parent cc80ef13c0749572901db232b551a2f14086f785
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 04:00:48 +0000

blossom: add BUD-11 HTTP authorization

- add strict authored and tolerant inbound BUD-11 claim typestates
- verify canonical signed Nostr authorization headers before claim exposure
- prevent raw authorization events from entering typed relay publication paths
- register executable vectors, coverage profiles, and Nix contract checks

Diffstat:
MCargo.lock | 2++
Mbuild/nix/common.nix | 5++++-
Acontracts/conformance/vectors/blossom/bud11_claims.v1.json | 964+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage-profiles.toml | 5+++++
Acontracts/events/blossom-authorization.md | 271+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/manifest.toml | 6+++++-
Mcontracts/operations.toml | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/blossom/README | 10+++++++---
Acrates/blossom/src/authorization.rs | 1227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/error.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/blossom/src/lib.rs | 10++++++++++
Acrates/blossom/tests/bud11_conformance.rs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/tests/fixtures/bud11_claims.v1.json | 964+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/Cargo.toml | 3+++
Mcrates/nostr/README | 7+++++++
Acrates/nostr/src/blossom.rs | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/lib.rs | 12++++++++++++
Acrates/nostr/tests/blossom_conformance.rs | 615+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 files changed, 5696 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4459,8 +4459,10 @@ dependencies = [ name = "radroots_nostr" version = "0.1.0-alpha.2" dependencies = [ + "base64 0.22.1", "nostr", "nostr-sdk", + "radroots_blossom", "radroots_event", "radroots_event_codec", "radroots_identity", diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -94,10 +94,13 @@ let "radroots_identity" "radroots_replica_schema" "radroots_event_codec" + "radroots_nostr" "radroots_nostr_connect" "radroots_nostr_signer" ]; - coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates); + coreContractCargoArgs = + lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) + + " --features radroots_nostr/blossom"; craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable; commonCraneArgs = { inherit version; diff --git a/contracts/conformance/vectors/blossom/bud11_claims.v1.json b/contracts/conformance/vectors/blossom/bud11_claims.v1.json @@ -0,0 +1,964 @@ +{ + "suite": "blossom_bud11_claims", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "action-get", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "get" }, + "expected": { "action": "get" } + }, + { + "id": "action-upload", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "upload" }, + "expected": { "action": "upload" } + }, + { + "id": "action-list", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "list" }, + "expected": { "action": "list" } + }, + { + "id": "action-delete", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "delete" }, + "expected": { "action": "delete" } + }, + { + "id": "action-media", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "media" }, + "expected": { "action": "media" } + }, + { + "id": "action-empty", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-uppercase", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "Upload" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-leading-space", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": " upload" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-trailing-space", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "upload " }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-unknown", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "mirror" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "domain-basic", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "blossom.radroots.test" }, + "expected": { "domain": "blossom.radroots.test" } + }, + { + "id": "domain-localhost", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "localhost" }, + "expected": { "domain": "localhost" } + }, + { + "id": "domain-hyphen-and-digit", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "blob-2.example.test" }, + "expected": { "domain": "blob-2.example.test" } + }, + { + "id": "domain-canonical-ipv4", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "127.0.0.1" }, + "expected": { "domain": "127.0.0.1" } + }, + { + "id": "domain-max-label", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" }, + "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" } + }, + { + "id": "domain-empty", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-uppercase", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "Blossom.Radroots.Test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-scheme", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "https://blossom.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-port", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test:443" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-path", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test/upload" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-trailing-dot", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test." }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-empty-label", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom..test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-leading-hyphen", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "-blossom.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-trailing-hyphen", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom-.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-underscore", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom_server.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-label-too-long", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-short-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "127.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-leading-zero-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "127.00.0.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-out-of-range-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "256.0.0.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "content-upload", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Upload farm photo" }, + "expected": { "content": "Upload farm photo" } + }, + { + "id": "content-delete", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Delete old farm photo" }, + "expected": { "content": "Delete old farm photo" } + }, + { + "id": "content-unicode", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Téléverser la photo" }, + "expected": { "content": "Téléverser la photo" } + }, + { + "id": "content-punctuation", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Upload: photo #2!" }, + "expected": { "content": "Upload: photo #2!" } + }, + { + "id": "content-empty", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-space-only", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": " " }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-leading-space", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": " Upload farm photo" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-trailing-space", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload farm photo " }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-newline", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\nphoto" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-tab", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\tphoto" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-control", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\u0000photo" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "claim-parse-upload", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + }, + "expected": { + "content": "Upload farm photo", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["blossom.radroots.test"], + "hashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + }, + { + "id": "claim-parse-unknown-tags-tolerated", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "List farm photos", + "created_at": 1999999999, + "tags": [ + ["client", "radroots", "future", "fields"], + ["t", "list"], + ["expiration", "2000000001"], + ["future-only"], + [] + ] + }, + "expected": { + "content": "List farm photos", + "created_at": 1999999999, + "action": "list", + "expiration": 2000000001, + "servers": [], + "hashes": [] + } + }, + { + "id": "claim-parse-known-trailing-elements-tolerated", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload", "ignored"], + ["expiration", "2000000060", "ignored", "again"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "ignored"], + ["server", "blossom.radroots.test", "ignored"] + ] + }, + "expected": { + "content": "Upload farm photo", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["blossom.radroots.test"], + "hashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + }, + { + "id": "claim-parse-multiple-scopes", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload selected farm photos", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["server", "other.example.test"], + ["server", "blossom.radroots.test"], + ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + ] + }, + "expected": { + "content": "Upload selected farm photos", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["other.example.test", "blossom.radroots.test"], + "hashes": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "id": "claim-parse-uppercase-tag-names-unknown", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Get farm photo", + "created_at": 1999999999, + "tags": [ + ["T", "delete"], + ["Expiration", "1"], + ["Server", "BAD"], + ["X", "BAD"], + ["t", "get"], + ["expiration", "2000000001"] + ] + }, + "expected": { + "content": "Get farm photo", + "created_at": 1999999999, + "action": "get", + "expiration": 2000000001, + "servers": [], + "hashes": [] + } + }, + { + "id": "claim-parse-invalid-content", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"]] + }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "claim-parse-missing-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["expiration", "2000000060"]] + }, + "expected": { "error": "missing_authorization_action_tag" } + }, + { + "id": "claim-parse-duplicate-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["t", "upload"], ["expiration", "2000000060"]] + }, + "expected": { "error": "duplicate_authorization_action_tag" } + }, + { + "id": "claim-parse-conflicting-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["t", "delete"], ["expiration", "2000000060"]] + }, + "expected": { "error": "duplicate_authorization_action_tag" } + }, + { + "id": "claim-parse-action-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t"], ["expiration", "2000000060"]] + }, + "expected": { "error": "malformed_authorization_action_tag" } + }, + { + "id": "claim-parse-action-invalid-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "UPLOAD"], ["expiration", "2000000060"]] + }, + "expected": { "error": "malformed_authorization_action_tag" } + }, + { + "id": "claim-parse-missing-expiration", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"]] + }, + "expected": { "error": "missing_authorization_expiration_tag" } + }, + { + "id": "claim-parse-duplicate-expiration", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["expiration", "2000000061"]] + }, + "expected": { "error": "duplicate_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-nondigit", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "later"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-negative", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "-1"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-overflow", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "18446744073709551616"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-server-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["server"]] + }, + "expected": { "error": "malformed_authorization_server_tag" } + }, + { + "id": "claim-parse-server-malformed-before-match", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["server", "https://bad.example"], + ["server", "blossom.radroots.test"] + ] + }, + "expected": { "error": "malformed_authorization_server_tag" } + }, + { + "id": "claim-parse-hash-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["x"]] + }, + "expected": { "error": "malformed_authorization_hash_tag" } + }, + { + "id": "claim-parse-hash-malformed-before-match", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["x", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + ] + }, + "expected": { "error": "malformed_authorization_hash_tag" } + }, + { + "id": "validation-max-age-zero", + "kind": "blossom.bud11.validation.new.valid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 0 + }, + "expected": { "max_created_age": 0 } + }, + { + "id": "validation-max-age-300", + "kind": "blossom.bud11.validation.new.valid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "max_created_age": 300 } + }, + { + "id": "validation-max-age-301", + "kind": "blossom.bud11.validation.new.invalid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 301 + }, + "expected": { "error": "invalid_authorization_created_age" } + }, + { + "id": "validate-get-no-hash-scope", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Get farm photo", "created_at": 2000000000, "tags": [["t", "get"], ["expiration", "2000000001"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "get" } + }, + { + "id": "validate-get-multiple-hash-any-match", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Get selected farm photo", "created_at": 1999999990, "tags": [["t", "get"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "get" } + }, + { + "id": "validate-upload", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-list", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-delete", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Delete farm photo", "created_at": 1999999990, "tags": [["t", "delete"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "delete_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "delete" } + }, + { + "id": "validate-mirror-uses-upload", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Mirror farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "mirror", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-media", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Transform farm photo", "created_at": 1999999990, "tags": [["t", "media"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "media", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "media" } + }, + { + "id": "validate-list-ignores-valid-hash-scopes", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-optional-server-absent", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-multiple-server-any-match", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], ["server", "other.example.test"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-multiple-hash-any-match-required", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload selected farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-created-at-now", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 0 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-horizon-300", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999701, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-action-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "delete_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_action_mismatch" } + }, + { + "id": "validate-optional-server-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "other.example.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_mismatch" } + }, + { + "id": "validate-required-server-missing", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_required" } + }, + { + "id": "validate-required-server-multiple-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "one.example.test"], ["server", "two.example.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_mismatch" } + }, + { + "id": "validate-required-hash-missing", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_required" } + }, + { + "id": "validate-required-hash-multiple-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload selected farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_mismatch" } + }, + { + "id": "validate-get-optional-hash-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Get selected farm photo", "created_at": 1999999990, "tags": [["t", "get"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_mismatch" } + }, + { + "id": "validate-created-at-future", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000001, "tags": [["t", "list"], ["expiration", "2000000002"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_created_in_future" } + }, + { + "id": "validate-created-at-stale-301", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999699, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_stale" } + }, + { + "id": "validate-expiration-at-now", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000000"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_expired" } + }, + { + "id": "validate-horizon-301", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000301"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-lifetime-1", + "kind": "blossom.bud11.authored_upload.valid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 1 + }, + "expected": { + "kind": 24242, + "content": "Upload farm photo", + "created_at": 2000000000, + "expiration": 2000000001, + "tags": [ + ["t", "upload"], + ["expiration", "2000000001"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + } + }, + { + "id": "authored-upload-lifetime-300", + "kind": "blossom.bud11.authored_upload.valid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 300 + }, + "expected": { + "kind": 24242, + "content": "Upload farm photo", + "created_at": 2000000000, + "expiration": 2000000300, + "tags": [ + ["t", "upload"], + ["expiration", "2000000300"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + } + }, + { + "id": "authored-upload-lifetime-zero", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 0 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-lifetime-301", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 301 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-expiration-overflow", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 18446744073709551615, + "lifetime": 1 + }, + "expected": { "error": "authorization_timestamp_overflow" } + } + ] +} diff --git a/contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json b/contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json @@ -0,0 +1,234 @@ +{ + "suite": "blossom_bud11_nostr_adapter", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "decode-verified-upload", + "kind": "blossom.bud11.nostr.decode_verify.valid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", + "server_scope": "required_any_match", + "now": 1700000001, + "max_created_age": 300 + } + }, + "expected": { + "event_id": "3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "action": "upload", + "content": "Upload Victoria farm photo", + "created_at": 1700000000, + "expiration": 1700000300, + "hashes": ["2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"], + "servers": ["cdn.example.com"] + } + }, + { + "id": "decode-leading-whitespace", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": " Nostr e30", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_whitespace" } + }, + { + "id": "decode-wrong-scheme", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Bearer e30", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_scheme" } + }, + { + "id": "decode-empty-payload", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr ", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "empty_header_payload" } + }, + { + "id": "decode-padding-forbidden", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr e30=", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "header_padding_forbidden" } + }, + { + "id": "decode-invalid-base64url-alphabet", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr +", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_base64" } + }, + { + "id": "decode-noncanonical-base64url-tail", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr Zh", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "noncanonical_header_base64" } + }, + { + "id": "decode-invalid-utf8", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr _w", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_utf8" } + }, + { + "id": "decode-invalid-event-json-shape", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr e30", + "event_json": "{}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-unknown-event-field", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSIsInVua25vd24iOnRydWV9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\",\"unknown\":true}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-duplicate-event-field", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsImtpbmQiOjI0MjQyLCJ0YWdzIjpbWyJ0IiwidXBsb2FkIl0sWyJleHBpcmF0aW9uIiwiMTcwMDAwMDMwMCJdLFsieCIsIjJjZjI0ZGJhNWZiMGEzMGUyNmU4M2IyYWM1YjllMjllMWIxNjFlNWMxZmE3NDI1ZTczMDQzMzYyOTM4Yjk4MjQiXSxbInNlcnZlciIsImNkbi5leGFtcGxlLmNvbSJdXSwiY29udGVudCI6IlVwbG9hZCBWaWN0b3JpYSBmYXJtIHBob3RvIiwic2lnIjoiODJjYTFhNjFlN2Q3YmQ5ZmJkODM1ZTZhNDY0YWM1NmJkZTliN2EzYjE4MDA4MjgzMjBjYTMwOGExMzVjMTQ0NGUxYjVmZDkxN2E1ZDIwN2VkYmU2ZDczN2NmYzQ3MGEwODRmMmRhZjNjMzgwN2YxNWIzY2VhNTZmNmI4OGM5NzkifQ", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-overflowed-event-kind", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6ODk3NzgsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":89778,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_kind", "actual_kind": 89778 } + }, + { + "id": "decode-wrong-event-kind", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjE4NjhlOGFkNGNhNjZiN2E5YmI2ZGRhYWVjZGU2ZTVjYzVkMTE2ODJlODdhYmI2NTBhNmNlODg1Mzg1NGVmMDUiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MSwidGFncyI6W10sImNvbnRlbnQiOiIiLCJzaWciOiI0ODM1MTgzZDkzMmQ3M2ZjNmFhYTdjMTM3NDE3YzliMTgzZjVhZmFmNDk5NGRkMzU3MzlmMjllYjgyYjUxOTI2YmNhZjgyZGY3NjFjMDlhZDhjYTAzNDY3MGI1MmVmMjQzM2U1MzU1OTgzYWU0NGViNzVlOTI0NWYyZmU2ZmU3NiJ9", + "event_json": "{\"id\":\"1868e8ad4ca66b7a9bb6ddaaecde6e5cc5d11682e87abb650a6ce8853854ef05\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"\",\"sig\":\"4835183d932d73fc6aaa7c137417c9b183f5afaf4994dd35739f29eb82b51926bcaf82df761c09ad8ca034670b52ef2433e5355983ae44eb75e9245f2fe6fe76\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_kind", "actual_kind": 1 } + }, + { + "id": "decode-invalid-event-id-before-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG94Iiwic2lnIjoiODJjYTFhNjFlN2Q3YmQ5ZmJkODM1ZTZhNDY0YWM1NmJkZTliN2EzYjE4MDA4MjgzMjBjYTMwOGExMzVjMTQ0NGUxYjVmZDkxN2E1ZDIwN2VkYmU2ZDczN2NmYzQ3MGEwODRmMmRhZjNjMzgwN2YxNWIzY2VhNTZmNmI4OGM5NzkifQ", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photox\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_id" } + }, + { + "id": "decode-invalid-event-signature-before-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI1YmIzMmVkZjMwY2MxYTJkNDlhNTUxYzc2YTExMzgxNDk4NzI1MjBlMDI4NjI4MjM5MjMzNTFjOGEyMzZkZTMwMjQxYTNlNDNkYjBlYTcyZWU4MTBmNGM4N2ZjYTQxMGU3ZTM5MjAyYmE2MDQ5NzM0ZWYzMTU3Y2VhYTRkOWEyZSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"5bb32edf30cc1a2d49a551c76a1138149872520e02862823923351c8a236de30241a3e43db0ea72ee810f4c87fca410e7e39202ba6049734ef3157ceaa4d9a2e\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_signature" } + }, + { + "id": "decode-authenticated-invalid-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6Ijc4YmVlMjI0NDIxNjA3MjNlMDRiZTgyYzhiZDAwNzVjNzkwNDY1YjRiOWYwYzFlMTllYzFiMWRhZWRlMGY0YjEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltdLCJjb250ZW50IjoiIiwic2lnIjoiZGQwY2UwNTNiOTNlZmY2N2IyNTZhODA0YWI4MjE0M2JhY2YyMzE0ZTNhZDNmYjU0MGZkNzE5ZGU0N2NjN2M4NGNiNDhlMTQwYWJhMzJhNTFiZmMwYmI1ODNlOThjMDllNDliMjU2MTkxZDJjYzY5OGU4YTA1ODJlOTZlODY2MzAifQ", + "event_json": "{\"id\":\"78bee22442160723e04be82c8bd0075c790465b4b9f0c1e19ec1b1daede0f4b1\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[],\"content\":\"\",\"sig\":\"dd0ce053b93eff67b256a804ab82143bacf2314e3ad3fb540fd719de47cc7c84cb48e140aba32a51bfc0bb583e98c09e49b256191d2cc698e8a0582e96e86630\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_authorization_content" } + } + ] +} diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -17,3 +17,8 @@ test_threads = 1 no_default_features = true features = ["std"] test_threads = 1 + +[profiles.crates."radroots_nostr"] +no_default_features = false +features = ["blossom"] +test_threads = 1 diff --git a/contracts/events/blossom-authorization.md b/contracts/events/blossom-authorization.md @@ -0,0 +1,271 @@ +# Blossom BUD-11 Authorization Foundation + +Status: active implementation contract + +Scope: public, runtime-independent parsing, endpoint validation, and strict upload-claim authoring +for Blossom BUD-11 authorization claims. + +## Protocol Basis + +This contract is based on +[Blossom BUD-11](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/11.md) +at Blossom commit `b5bd2801d1763aa635fc8fea7a76597e0eb18990`. + +The example event in that upstream revision is not conformance evidence: its empty content violates +the same revision's human-readable-content requirement, its `created_at` is later than its +`expiration`, and the displayed signature therefore cannot establish a valid claim. Radroots uses +independent vectors and does not copy that example as a positive fixture. + +BUD-11 claims are Nostr kind `24242` HTTP authorization tokens. They are ephemeral request +credentials. They are never relay-published, persisted in canonical relay fixtures, or projected as +product events. + +## Ownership And Dependency Boundary + +`radroots_blossom` owns pure BUD-11 vocabulary, tag parsing, endpoint scope validation, and strict +upload-claim construction as a public `no_std + alloc` protocol leaf. It accepts content, +`created_at`, and raw string tag arrays rather than depending on Nostr event types. + +The outward `radroots_nostr` adapter owns the signed kind-`24242` event envelope, event-id and +signature verification, JSON encoding, and canonical Base64url-without-padding `Authorization: +Nostr` header. It emits the canonical `Nostr ` scheme spelling and one space. Inbound decoding +matches the HTTP authentication scheme case-insensitively and accepts the RFC 9110 `1*SP` +separator while retaining strict Base64url credential validation. HTTP clients and servers own +transport. Entitlement, rate limits, replay storage, production-domain policy, and any private +all-server-match rule remain outside this public crate. + +## Actions And Endpoint Targets + +`RadrootsBlossomAuthorizationAction` has exactly the five lowercase BUD-11 wire values `get`, +`upload`, `list`, `delete`, and `media`. Case variants, whitespace, unknown verbs, and the empty +string are invalid. + +Endpoint validation uses an explicit `RadrootsBlossomAuthorizationTarget`: + +| Endpoint | Target | Required action | Implied hash | `x` policy | +| --- | --- | --- | --- | --- | +| `GET /<sha256>` or `HEAD /<sha256>` | `GetBlob(hash)` | `get` | URL hash | optional when absent; any-match when present | +| `PUT /upload` or `HEAD /upload` | `Upload(hash)` | `upload` | `X-SHA-256` | required, any-match | +| `GET /list/<pubkey>` | `List` | `list` | none | not applicable | +| `DELETE /<sha256>` | `DeleteBlob(hash)` | `delete` | URL hash | required, any-match | +| `PUT /mirror` | `Mirror(hash)` | `upload` | mirrored blob hash | required, any-match | +| `PUT /media` or `HEAD /media` | `Media(hash)` | `media` | `X-SHA-256` | required, any-match | + +The target model intentionally collapses HTTP methods that have identical BUD-11 requirements. It +does not authorize arbitrary endpoints and does not perform HTTP parsing. + +## Human-Readable Content + +`RadrootsBlossomAuthorizationContent` is nonempty, contains no Unicode control character, and is +already equal to its Unicode-whitespace-trimmed form. This rejects empty and whitespace-only input, +leading or trailing whitespace, line breaks, tabs, and embedded controls. Interior ordinary spaces +and non-ASCII human-readable text are retained. Parsing does not silently trim or rewrite signed +content. + +This requirement is structural and deterministic. It cannot prove that wording accurately +describes a request, so product and runtime layers remain responsible for choosing clear text such +as `Upload farm photo`. + +## Server Domains + +A `RadrootsBlossomServerDomain` is an ASCII lowercase domain name of at most 253 bytes without +scheme, user information, port, path, query, fragment, IP-literal brackets, or trailing dot. Every +dot-separated label is from 1 through 63 bytes, begins and ends with an ASCII lowercase letter or +digit, and otherwise contains only ASCII lowercase letters, digits, or `-`. `localhost` is valid. +An all-numeric host spelling is valid only when it is the exact canonical four-octet dotted-decimal +IPv4 form; shortened, leading-zero, and out-of-range forms are rejected. Input is validated exactly +and is not lowercased or otherwise normalized. + +`server` tags are optional in the BUD-11 protocol. With `OptionalAnyMatch`, no `server` tags means +the claim is valid for any server. When one or more are present, at least one must equal the +validation server. `RequiredAnyMatch` additionally rejects absence. Multiple valid server tags are +allowed, and a matching tag makes a mixed set valid. A private deployment may require every tag to +match, but that stricter policy is deliberately not part of this public protocol contract. + +Strict authored Radroots upload claims require exactly one caller-supplied server domain. This +reduces replay scope without changing tolerant inbound BUD-11 semantics. + +## Blob Hash Scope + +Every known `x` tag value is a `RadrootsBlossomSha256`: exactly 64 lowercase hexadecimal +characters. Multiple valid `x` tags are allowed. + +For targets requiring a hash scope, at least one `x` tag must equal the target's implied hash. Other +valid hashes may coexist because BUD-11 specifies any-match behavior. A `GetBlob` claim may omit +`x`; if any `x` tags are present, at least one must match. `List` has no implied hash and does not +apply hash matching, although every present known `x` tag must still be structurally valid. + +Strict authored upload claims contain exactly one `x` tag equal to the upload hash. + +## Claim Tag Parsing + +`RadrootsBlossomParsedAuthorizationClaim::parse` consumes signed content, the event `created_at` +timestamp, and raw Nostr tag arrays. Structural parsing occurs before endpoint policy validation. + +Known tags use exact lowercase names and require at least a key and value: + +- exactly one `["t", "<action>", ...]` is required +- exactly one `["expiration", "<unsigned-decimal-unix-seconds>", ...]` is required +- zero or more `["server", "<domain>", ...]` tags are allowed +- zero or more `["x", "<lowercase-sha256>", ...]` tags are allowed + +A missing, duplicate, malformed, or empty-valued `t` or `expiration` tag fails parsing. Malformed +or empty-valued `server` and `x` tags fail parsing even when another value would satisfy endpoint +policy. Consistent with NIP-01, trailing elements after a known tag's value are tolerated and do not +change its meaning. Repeated `server` and `x` tags are protocol scopes rather than duplicate +singleton fields and are retained in wire order. + +Unknown tag names are ignored, including future tags with additional fields. They cannot satisfy a +known requirement. Case variants such as `T`, `Expiration`, `Server`, or `X` are unknown Nostr tags +and do not alias the lowercase BUD-11 names. + +Parsing does not validate the Nostr kind, event id, signature, HTTP header, clock, endpoint action, +or target scope. Those checks belong to the appropriate outward adapter or validation transition. + +## Time Validation + +Endpoint validation receives an explicit `now` and maximum creation age so it is deterministic and +does not read a wall clock. Radroots uses +`RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS = 300`. + +The requested maximum creation age may be from 0 through 300 seconds inclusive. A value above the +public cap is rejected when the validation policy is constructed rather than weakening the replay +window by accident. + +- `created_at == now` is accepted to tolerate integer-second signing and validation in the same + second +- `created_at > now` is rejected as future-dated +- `now - created_at <= max_created_age` is accepted +- an older value is rejected as stale, using checked comparisons without unsigned wraparound +- `expiration > now` is required; `expiration == now` is expired +- `expiration - created_at` must be from 1 through + `RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS = 300` seconds inclusive + +The 300-second horizon is the Radroots replay-limiting application profile layered on BUD-11. +Strict authored upload construction applies the same bound: lifetime must be from 1 through 300 +seconds inclusive, and `created_at + lifetime` must not overflow `u64`. + +## Strict Authored Upload Claims + +`RadrootsBlossomAuthoredUploadClaim::new` requires typed human-readable content, one typed server +domain, one exact typed SHA-256, `created_at`, and a valid authored lifetime. It produces canonical +wire parts in this exact order: + +```text +["t", "upload"] +["expiration", "<created_at-plus-lifetime>"] +["x", "<lowercase-sha256>"] +["server", "<lowercase-domain>"] +``` + +The constructor neither signs nor serializes a Nostr event. Callers must use the named Nostr +adapter, which must sign kind `24242` and must not offer relay publication as an authorization path. +The signed and verified authorization typestates keep the raw event private; they expose only its +event id, author, creation timestamp, and validated claim data. + +Fresh Schnorr signing intentionally uses auxiliary randomness and is not modeled or registered as a +deterministic machine operation. Canonical header encoding is deterministic for an already signed +opaque authorization value, but that opaque typestate cannot be reconstructed from a fixed vector +without weakening the API. The operation registry intentionally enumerates reproducible machine +operations rather than every public helper. Fresh signing and encoding therefore remain directly +tested but outside deterministic machine-operation registration. Decode/verify is registered +directly, and fixed checked-in signed-event vectors make header decoding, id verification, +signature verification, and pure-claim validation reproducible without asserting dynamic signature +equality. + +## Stable Conformance Operations + +`contracts/conformance/vectors/blossom/bud11_claims.v1.json` is executable evidence for this +contract. Integration tests dispatch every vector through the public API using these kinds: + +- `blossom.bud11.action.parse.valid` and `blossom.bud11.action.parse.invalid` +- `blossom.bud11.server_domain.parse.valid` and + `blossom.bud11.server_domain.parse.invalid` +- `blossom.bud11.content.parse.valid` and `blossom.bud11.content.parse.invalid` +- `blossom.bud11.claim.parse.valid` and `blossom.bud11.claim.parse.invalid` +- `blossom.bud11.validation.new.valid` and `blossom.bud11.validation.new.invalid` +- `blossom.bud11.claim.validate.valid` and `blossom.bud11.claim.validate.invalid` +- `blossom.bud11.authored_upload.valid` and `blossom.bud11.authored_upload.invalid` + +`contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json` supplies fixed signed-event and +header evidence for the outward adapter. Its +`blossom.bud11.nostr.decode_verify.valid` and +`blossom.bud11.nostr.decode_verify.invalid` kinds execute canonical Base64url decoding, exact raw +event bytes, kind checks, event-id checks, signature checks, and pure claim validation in that +order. Syntax-only vectors cover whitespace, a wrong scheme, empty payload, padding, alphabet, +canonical tail bits, UTF-8, and JSON failures. Dynamic adapter tests additionally prove +case-insensitive `Nostr` matching and a one-or-more-space separator with a valid signed credential. +Cryptographic vectors contain immutable signed material; tests do not mint a fresh signature and +compare it for equality. + +The adapter rejects unknown or duplicate top-level event fields rather than accepting ambiguous +credential JSON representations; a generic JSON map must not make a duplicated field acceptable by +collapsing it. The adapter validates the JSON kind as a non-truncated integer before converting to a +Nostr kind, so a value greater than `u16::MAX` cannot wrap into `24242`. + +Invalid vectors use the exact stable identifier returned by the owning Blossom or Nostr adapter +error's `code()` method. The pure suite is mirrored at +`crates/blossom/tests/fixtures/bud11_claims.v1.json`; the signed adapter suite is mirrored at +`crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json`. Source-workspace tests require each +canonical file and assert byte-for-byte equality with its packaged mirror. A missing canonical file +falls back to the applicable mirror only when the positive Radroots workspace contract marker is +also absent, which prevents a broken source checkout from silently testing stale packaged data. + +## Stable Error Identifiers + +The public typed API exposes these stable BUD-11 identifiers: + +- `invalid_authorization_content` +- `invalid_authorization_action` +- `invalid_authorization_server_domain` +- `missing_authorization_action_tag` +- `duplicate_authorization_action_tag` +- `malformed_authorization_action_tag` +- `missing_authorization_expiration_tag` +- `duplicate_authorization_expiration_tag` +- `malformed_authorization_expiration_tag` +- `malformed_authorization_server_tag` +- `malformed_authorization_hash_tag` +- `invalid_authorization_created_age` +- `invalid_authorization_lifetime` +- `authorization_timestamp_overflow` +- `authorization_created_in_future` +- `authorization_stale` +- `authorization_expired` +- `authorization_action_mismatch` +- `authorization_server_required` +- `authorization_server_mismatch` +- `authorization_hash_required` +- `authorization_hash_mismatch` + +The outward Nostr adapter additionally exposes these stable boundary identifiers: + +- `invalid_header_whitespace` +- `invalid_header_scheme` +- `empty_header_payload` +- `header_padding_forbidden` +- `invalid_header_base64` +- `noncanonical_header_base64` +- `invalid_header_utf8` +- `invalid_event_json` +- `invalid_event_kind` +- `invalid_event_id` +- `invalid_event_signature` +- `event_signing` + +An authenticated pure-claim failure retains its `RadrootsBlossomError::code()` identifier rather +than being collapsed into a generic adapter error. + +Direct typed action, server-domain, and hash parsing uses `invalid_authorization_action`, +`invalid_authorization_server_domain`, and the existing `invalid_sha256`. Inside a claim, a known +tag with a missing or semantically invalid value uses its contextual malformed-tag identifier. +Trailing elements remain tolerated. + +## Security Boundary + +A validated pure claim proves only that structurally parsed claim data satisfies one declared +target, server, hash-scope, and clock policy. It does not prove the kind, id, signature, signer +identity, header encoding, network destination, entitlement, possession of bytes, upload success, +single use, or absence of replay. The Nostr adapter must verify the signed envelope before exposing +the claim, and the owning HTTP service must enforce authorization and replay-sensitive runtime +policy. diff --git a/contracts/manifest.toml b/contracts/manifest.toml @@ -12,7 +12,11 @@ model_crates = [ "radroots_trade", "radroots_identity", ] -algorithm_crates = ["radroots_blossom", "radroots_event_codec"] +algorithm_crates = [ + "radroots_blossom", + "radroots_event_codec", + "radroots_nostr", +] [surface.rust_crate_tiers] advanced_substrate = [ diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -27,6 +27,19 @@ public = [ "RadrootsBlossomApprovedDescriptor", "RadrootsBlossomByteCommitment", "RadrootsBlossomByteVerifiedDescriptor", + "RadrootsBlossomAuthorizationAction", + "RadrootsBlossomAuthorizationContent", + "RadrootsBlossomServerDomain", + "RadrootsBlossomAuthorizationTarget", + "RadrootsBlossomServerScopeRequirement", + "RadrootsBlossomAuthorizationValidation", + "RadrootsBlossomParsedAuthorizationClaim", + "RadrootsBlossomValidatedAuthorizationClaim", + "RadrootsBlossomAuthorizationWireParts", + "RadrootsBlossomAuthoredUploadClaim", + "RadrootsNostrSignedBlossomAuthorization", + "RadrootsNostrBlossomAuthorizationHeader", + "RadrootsNostrVerifiedBlossomAuthorization", "RadrootsNip01EventWireParts", "RadrootsEventDraft", "RadrootsSignedEvent", @@ -111,7 +124,11 @@ model_crates = [ "radroots_trade", "radroots_identity", ] -algorithm_crates = ["radroots_blossom", "radroots_event_codec"] +algorithm_crates = [ + "radroots_blossom", + "radroots_event_codec", + "radroots_nostr", +] [operations.blossom_parse_hash_path] domain = "blossom" @@ -186,6 +203,105 @@ rust_types = [ [operations.blossom_verify_descriptor_bytes.conformance] vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json" +[operations.blossom_build_upload_authorization_claim] +domain = "blossom" +id = "blossom.build_upload_authorization_claim" +stability = "beta" +inputs = [ + "RadrootsBlossomAuthorizationContent", + "RadrootsBlossomServerDomain", + "RadrootsBlossomSha256", + "CreatedAt", + "LifetimeSeconds", +] +outputs = [ + "RadrootsBlossomAuthoredUploadClaim", + "RadrootsBlossomAuthorizationWireParts", +] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_build_upload_authorization_claim.implementation] +rust_modules = ["crates/blossom/src/authorization.rs"] +rust_types = [ + "radroots_blossom::RadrootsBlossomAuthorizationWireParts", + "radroots_blossom::RadrootsBlossomAuthoredUploadClaim", +] + +[operations.blossom_build_upload_authorization_claim.conformance] +vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" + +[operations.blossom_parse_authorization_claim] +domain = "blossom" +id = "blossom.parse_authorization_claim" +stability = "beta" +inputs = ["String", "CreatedAt", "NostrTags"] +outputs = ["RadrootsBlossomParsedAuthorizationClaim"] +error_class = "parse_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_parse_authorization_claim.implementation] +rust_modules = ["crates/blossom/src/authorization.rs"] +rust_types = ["radroots_blossom::RadrootsBlossomParsedAuthorizationClaim"] + +[operations.blossom_parse_authorization_claim.conformance] +vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" + +[operations.blossom_validate_authorization_claim] +domain = "blossom" +id = "blossom.validate_authorization_claim" +stability = "beta" +inputs = [ + "RadrootsBlossomParsedAuthorizationClaim", + "RadrootsBlossomAuthorizationValidation", +] +outputs = ["RadrootsBlossomValidatedAuthorizationClaim"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_validate_authorization_claim.implementation] +rust_modules = ["crates/blossom/src/authorization.rs"] +rust_types = [ + "radroots_blossom::RadrootsBlossomAuthorizationValidation", + "radroots_blossom::RadrootsBlossomValidatedAuthorizationClaim", +] + +[operations.blossom_validate_authorization_claim.conformance] +vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json" + +[operations.blossom_decode_verify_authorization_header] +domain = "blossom" +id = "blossom.decode_verify_authorization_header" +stability = "beta" +inputs = [ + "String", + "RadrootsBlossomAuthorizationValidation", +] +outputs = ["RadrootsNostrVerifiedBlossomAuthorization"] +error_class = "decode_error" +deterministic = true +signing = "nip01" +transport = "none" + +[operations.blossom_decode_verify_authorization_header.implementation] +rust_modules = [ + "crates/blossom/src/authorization.rs", + "crates/nostr/src/blossom.rs", +] +rust_types = [ + "radroots_blossom::RadrootsBlossomAuthorizationValidation", + "radroots_nostr::blossom::RadrootsNostrVerifiedBlossomAuthorization", +] + +[operations.blossom_decode_verify_authorization_header.conformance] +vector = "contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json" + [operations.profile_build_draft] domain = "profile" id = "profile.build_draft" diff --git a/crates/blossom/README b/crates/blossom/README @@ -1,8 +1,8 @@ # radroots_blossom `radroots_blossom` provides portable, runtime-independent primitives for -Blossom blob hashes, root hash paths, blob URLs, BUD-02 descriptors, and -Radroots-approved byte verification. +Blossom blob hashes, root hash paths, blob URLs, BUD-02 descriptors, +Radroots-approved byte verification, and pure BUD-11 authorization claims. The crate is `no_std + alloc`, performs no HTTP requests, and does not depend on Nostr event types. Structural Blossom validity is kept separate from the @@ -10,10 +10,14 @@ stricter Radroots reference policy: public HTTP descriptors remain parseable, but only HTTPS and loopback HTTP references can advance to an approved state. The byte-verified descriptor state proves local descriptor-to-byte agreement; it is not an upload receipt, and HTTP-capable runtimes must gate successful -BUD-02 upload completion separately. +BUD-02 upload completion separately. BUD-11 support similarly stops at typed +claim construction and endpoint validation; signing and canonical +`Authorization: Nostr` encoding live behind the `radroots_nostr` `blossom` +feature, and kind `24242` is never a relay-publication event. Protocol behavior is pinned to Blossom commit `b5bd2801d1763aa635fc8fea7a76597e0eb18990`: - BUD-01: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/01.md> - BUD-02: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/02.md> +- BUD-11: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/11.md> diff --git a/crates/blossom/src/authorization.rs b/crates/blossom/src/authorization.rs @@ -0,0 +1,1227 @@ +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::{fmt, str::FromStr}; + +use crate::{RadrootsBlossomError, RadrootsBlossomSha256}; + +pub const RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND: u16 = 24_242; +pub const RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS: u64 = 300; +pub const RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS: u64 = 300; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomAuthorizationAction { + Get, + Upload, + List, + Delete, + Media, +} + +impl RadrootsBlossomAuthorizationAction { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + match value { + "get" => Ok(Self::Get), + "upload" => Ok(Self::Upload), + "list" => Ok(Self::List), + "delete" => Ok(Self::Delete), + "media" => Ok(Self::Media), + _ => Err(RadrootsBlossomError::InvalidAuthorizationAction), + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + Self::Upload => "upload", + Self::List => "list", + Self::Delete => "delete", + Self::Media => "media", + } + } +} + +impl fmt::Display for RadrootsBlossomAuthorizationAction { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomAuthorizationAction { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomServerDomain(String); + +impl RadrootsBlossomServerDomain { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + if value.is_empty() + || value.len() > 253 + || !value.is_ascii() + || value.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + } + + let mut all_labels_are_numeric = true; + for label in value.split('.') { + if label.is_empty() + || label.len() > 63 + || !label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || !label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + { + return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + } + all_labels_are_numeric &= label.bytes().all(|byte| byte.is_ascii_digit()); + } + + if all_labels_are_numeric && !is_canonical_dotted_ipv4(value) { + return Err(RadrootsBlossomError::InvalidAuthorizationServerDomain); + } + + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RadrootsBlossomServerDomain { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomServerDomain { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomAuthorizationContent(String); + +impl RadrootsBlossomAuthorizationContent { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { + return Err(RadrootsBlossomError::InvalidAuthorizationContent); + } + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RadrootsBlossomAuthorizationContent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomAuthorizationContent { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomAuthorizationTarget { + GetBlob(RadrootsBlossomSha256), + Upload(RadrootsBlossomSha256), + List, + DeleteBlob(RadrootsBlossomSha256), + Mirror(RadrootsBlossomSha256), + Media(RadrootsBlossomSha256), +} + +impl RadrootsBlossomAuthorizationTarget { + pub const fn action(self) -> RadrootsBlossomAuthorizationAction { + match self { + Self::GetBlob(_) => RadrootsBlossomAuthorizationAction::Get, + Self::Upload(_) | Self::Mirror(_) => RadrootsBlossomAuthorizationAction::Upload, + Self::List => RadrootsBlossomAuthorizationAction::List, + Self::DeleteBlob(_) => RadrootsBlossomAuthorizationAction::Delete, + Self::Media(_) => RadrootsBlossomAuthorizationAction::Media, + } + } + + pub const fn implied_hash(self) -> Option<RadrootsBlossomSha256> { + match self { + Self::GetBlob(hash) + | Self::Upload(hash) + | Self::DeleteBlob(hash) + | Self::Mirror(hash) + | Self::Media(hash) => Some(hash), + Self::List => None, + } + } + + const fn requires_hash_tag(self) -> bool { + match self { + Self::GetBlob(_) | Self::List => false, + Self::Upload(_) | Self::DeleteBlob(_) | Self::Mirror(_) | Self::Media(_) => true, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RadrootsBlossomServerScopeRequirement { + OptionalAnyMatch, + RequiredAnyMatch, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomAuthorizationValidation { + target: RadrootsBlossomAuthorizationTarget, + target_server: RadrootsBlossomServerDomain, + server_scope_requirement: RadrootsBlossomServerScopeRequirement, + now: u64, + max_created_age_seconds: u64, +} + +impl RadrootsBlossomAuthorizationValidation { + pub fn new( + target: RadrootsBlossomAuthorizationTarget, + target_server: RadrootsBlossomServerDomain, + server_scope_requirement: RadrootsBlossomServerScopeRequirement, + now: u64, + max_created_age_seconds: u64, + ) -> Result<Self, RadrootsBlossomError> { + if max_created_age_seconds > RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS { + return Err(RadrootsBlossomError::InvalidAuthorizationCreatedAge); + } + Ok(Self { + target, + target_server, + server_scope_requirement, + now, + max_created_age_seconds, + }) + } + + pub const fn target(&self) -> RadrootsBlossomAuthorizationTarget { + self.target + } + + pub fn target_server(&self) -> &RadrootsBlossomServerDomain { + &self.target_server + } + + pub const fn server_scope_requirement(&self) -> RadrootsBlossomServerScopeRequirement { + self.server_scope_requirement + } + + pub const fn now(&self) -> u64 { + self.now + } + + pub const fn max_created_age_seconds(&self) -> u64 { + self.max_created_age_seconds + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomParsedAuthorizationClaim { + content: RadrootsBlossomAuthorizationContent, + created_at: u64, + action: RadrootsBlossomAuthorizationAction, + expiration: u64, + server_domains: Vec<RadrootsBlossomServerDomain>, + hashes: Vec<RadrootsBlossomSha256>, +} + +impl RadrootsBlossomParsedAuthorizationClaim { + pub fn parse( + content: &str, + created_at: u64, + tags: &[Vec<String>], + ) -> Result<Self, RadrootsBlossomError> { + let content = RadrootsBlossomAuthorizationContent::parse(content)?; + let mut action = None; + let mut expiration = None; + let mut server_domains = Vec::new(); + let mut hashes = Vec::new(); + + for tag in tags { + match tag.first().map(String::as_str) { + Some("t") => { + if action.is_some() { + return Err(RadrootsBlossomError::DuplicateAuthorizationActionTag); + } + let value = tag + .get(1) + .ok_or(RadrootsBlossomError::MalformedAuthorizationActionTag)?; + action = Some( + RadrootsBlossomAuthorizationAction::parse(value) + .map_err(|_| RadrootsBlossomError::MalformedAuthorizationActionTag)?, + ); + } + Some("expiration") => { + if expiration.is_some() { + return Err(RadrootsBlossomError::DuplicateAuthorizationExpirationTag); + } + let value = tag + .get(1) + .ok_or(RadrootsBlossomError::MalformedAuthorizationExpirationTag)?; + expiration = Some(parse_timestamp(value)?); + } + Some("server") => { + let value = tag + .get(1) + .ok_or(RadrootsBlossomError::MalformedAuthorizationServerTag)?; + server_domains.push( + RadrootsBlossomServerDomain::parse(value) + .map_err(|_| RadrootsBlossomError::MalformedAuthorizationServerTag)?, + ); + } + Some("x") => { + let value = tag + .get(1) + .ok_or(RadrootsBlossomError::MalformedAuthorizationHashTag)?; + hashes.push( + RadrootsBlossomSha256::from_hex(value) + .map_err(|_| RadrootsBlossomError::MalformedAuthorizationHashTag)?, + ); + } + _ => {} + } + } + + Ok(Self { + content, + created_at, + action: action.ok_or(RadrootsBlossomError::MissingAuthorizationActionTag)?, + expiration: expiration + .ok_or(RadrootsBlossomError::MissingAuthorizationExpirationTag)?, + server_domains, + hashes, + }) + } + + pub fn validate( + &self, + validation: &RadrootsBlossomAuthorizationValidation, + ) -> Result<RadrootsBlossomValidatedAuthorizationClaim, RadrootsBlossomError> { + if self.created_at > validation.now { + return Err(RadrootsBlossomError::AuthorizationCreatedInFuture); + } + if validation.now.saturating_sub(self.created_at) > validation.max_created_age_seconds { + return Err(RadrootsBlossomError::AuthorizationStale); + } + + let lifetime = self + .expiration + .checked_sub(self.created_at) + .filter(|lifetime| (1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(lifetime)) + .ok_or(RadrootsBlossomError::InvalidAuthorizationLifetime)?; + debug_assert!(lifetime <= RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS); + + if self.expiration <= validation.now { + return Err(RadrootsBlossomError::AuthorizationExpired); + } + if self.action != validation.target.action() { + return Err(RadrootsBlossomError::AuthorizationActionMismatch); + } + + self.validate_server_scope(validation)?; + self.validate_hash_scope(validation.target)?; + + Ok(RadrootsBlossomValidatedAuthorizationClaim { + claim: self.clone(), + target: validation.target, + target_server: validation.target_server.clone(), + }) + } + + pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + &self.content + } + + pub const fn created_at(&self) -> u64 { + self.created_at + } + + pub const fn action(&self) -> RadrootsBlossomAuthorizationAction { + self.action + } + + pub const fn expiration(&self) -> u64 { + self.expiration + } + + pub fn server_domains(&self) -> &[RadrootsBlossomServerDomain] { + &self.server_domains + } + + pub fn hashes(&self) -> &[RadrootsBlossomSha256] { + &self.hashes + } + + fn validate_server_scope( + &self, + validation: &RadrootsBlossomAuthorizationValidation, + ) -> Result<(), RadrootsBlossomError> { + if self.server_domains.is_empty() { + return match validation.server_scope_requirement { + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch => Ok(()), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch => { + Err(RadrootsBlossomError::AuthorizationServerRequired) + } + }; + } + if self + .server_domains + .iter() + .any(|server| server == &validation.target_server) + { + Ok(()) + } else { + Err(RadrootsBlossomError::AuthorizationServerMismatch) + } + } + + fn validate_hash_scope( + &self, + target: RadrootsBlossomAuthorizationTarget, + ) -> Result<(), RadrootsBlossomError> { + let Some(implied_hash) = target.implied_hash() else { + return Ok(()); + }; + if self.hashes.is_empty() { + return if target.requires_hash_tag() { + Err(RadrootsBlossomError::AuthorizationHashRequired) + } else { + Ok(()) + }; + } + if self.hashes.contains(&implied_hash) { + Ok(()) + } else { + Err(RadrootsBlossomError::AuthorizationHashMismatch) + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomValidatedAuthorizationClaim { + claim: RadrootsBlossomParsedAuthorizationClaim, + target: RadrootsBlossomAuthorizationTarget, + target_server: RadrootsBlossomServerDomain, +} + +impl RadrootsBlossomValidatedAuthorizationClaim { + pub fn claim(&self) -> &RadrootsBlossomParsedAuthorizationClaim { + &self.claim + } + + pub const fn target(&self) -> RadrootsBlossomAuthorizationTarget { + self.target + } + + pub fn target_server(&self) -> &RadrootsBlossomServerDomain { + &self.target_server + } + + pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + self.claim.content() + } + + pub const fn created_at(&self) -> u64 { + self.claim.created_at() + } + + pub const fn action(&self) -> RadrootsBlossomAuthorizationAction { + self.claim.action() + } + + pub const fn expiration(&self) -> u64 { + self.claim.expiration() + } + + pub fn server_domains(&self) -> &[RadrootsBlossomServerDomain] { + self.claim.server_domains() + } + + pub fn hashes(&self) -> &[RadrootsBlossomSha256] { + self.claim.hashes() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomAuthorizationWireParts { + kind: u16, + created_at: u64, + tags: Vec<Vec<String>>, + content: String, +} + +impl RadrootsBlossomAuthorizationWireParts { + pub const fn kind(&self) -> u16 { + self.kind + } + + pub const fn created_at(&self) -> u64 { + self.created_at + } + + pub fn tags(&self) -> &[Vec<String>] { + &self.tags + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn into_parts(self) -> (u16, u64, Vec<Vec<String>>, String) { + (self.kind, self.created_at, self.tags, self.content) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomAuthoredUploadClaim { + wire_parts: RadrootsBlossomAuthorizationWireParts, + content: RadrootsBlossomAuthorizationContent, + server_domain: RadrootsBlossomServerDomain, + sha256: RadrootsBlossomSha256, + expiration: u64, +} + +impl RadrootsBlossomAuthoredUploadClaim { + pub fn new( + content: RadrootsBlossomAuthorizationContent, + server_domain: RadrootsBlossomServerDomain, + sha256: RadrootsBlossomSha256, + created_at: u64, + lifetime_seconds: u64, + ) -> Result<Self, RadrootsBlossomError> { + if !(1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(&lifetime_seconds) { + return Err(RadrootsBlossomError::InvalidAuthorizationLifetime); + } + let expiration = created_at + .checked_add(lifetime_seconds) + .ok_or(RadrootsBlossomError::AuthorizationTimestampOverflow)?; + let tags = vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), expiration.to_string()], + vec!["x".to_string(), sha256.to_hex()], + vec!["server".to_string(), server_domain.as_str().to_string()], + ]; + let wire_parts = RadrootsBlossomAuthorizationWireParts { + kind: RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + created_at, + tags, + content: content.as_str().to_string(), + }; + Ok(Self { + wire_parts, + content, + server_domain, + sha256, + expiration, + }) + } + + pub fn content(&self) -> &RadrootsBlossomAuthorizationContent { + &self.content + } + + pub fn server_domain(&self) -> &RadrootsBlossomServerDomain { + &self.server_domain + } + + pub const fn sha256(&self) -> RadrootsBlossomSha256 { + self.sha256 + } + + pub const fn created_at(&self) -> u64 { + self.wire_parts.created_at + } + + pub const fn expiration(&self) -> u64 { + self.expiration + } + + pub const fn lifetime_seconds(&self) -> u64 { + self.expiration - self.wire_parts.created_at + } + + pub fn wire_parts(&self) -> &RadrootsBlossomAuthorizationWireParts { + &self.wire_parts + } + + pub fn into_wire_parts(self) -> RadrootsBlossomAuthorizationWireParts { + self.wire_parts + } +} + +fn parse_timestamp(value: &str) -> Result<u64, RadrootsBlossomError> { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(RadrootsBlossomError::MalformedAuthorizationExpirationTag); + } + value + .parse() + .map_err(|_| RadrootsBlossomError::MalformedAuthorizationExpirationTag) +} + +fn is_canonical_dotted_ipv4(value: &str) -> bool { + let mut count = 0_usize; + for octet in value.split('.') { + count += 1; + if (octet.len() > 1 && octet.starts_with('0')) || octet.parse::<u8>().is_err() { + return false; + } + } + count == 4 +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{format, vec}; + + const NOW: u64 = 1_800_000_000; + const HASH_HEX: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + + fn hash() -> RadrootsBlossomSha256 { + RadrootsBlossomSha256::from_hex(HASH_HEX).unwrap() + } + + fn server() -> RadrootsBlossomServerDomain { + RadrootsBlossomServerDomain::parse("media.example.com").unwrap() + } + + fn content() -> RadrootsBlossomAuthorizationContent { + RadrootsBlossomAuthorizationContent::parse("Upload Blob").unwrap() + } + + fn tags(action: &str, expiration: u64) -> Vec<Vec<String>> { + vec![ + vec!["t".to_string(), action.to_string()], + vec!["expiration".to_string(), expiration.to_string()], + vec!["x".to_string(), HASH_HEX.to_string()], + vec!["server".to_string(), "media.example.com".to_string()], + ] + } + + fn parse_upload() -> RadrootsBlossomParsedAuthorizationClaim { + RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - 10, + &tags("upload", NOW + 60), + ) + .unwrap() + } + + fn validation( + target: RadrootsBlossomAuthorizationTarget, + server_requirement: RadrootsBlossomServerScopeRequirement, + ) -> RadrootsBlossomAuthorizationValidation { + RadrootsBlossomAuthorizationValidation::new( + target, + server(), + server_requirement, + NOW, + RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, + ) + .unwrap() + } + + #[test] + fn actions_are_strict_lowercase_wire_verbs() { + let cases = [ + ("get", RadrootsBlossomAuthorizationAction::Get), + ("upload", RadrootsBlossomAuthorizationAction::Upload), + ("list", RadrootsBlossomAuthorizationAction::List), + ("delete", RadrootsBlossomAuthorizationAction::Delete), + ("media", RadrootsBlossomAuthorizationAction::Media), + ]; + for (wire, action) in cases { + assert_eq!(RadrootsBlossomAuthorizationAction::parse(wire), Ok(action)); + assert_eq!(wire.parse(), Ok(action)); + assert_eq!(action.as_str(), wire); + assert_eq!(action.to_string(), wire); + } + assert_eq!( + RadrootsBlossomAuthorizationAction::parse("Upload"), + Err(RadrootsBlossomError::InvalidAuthorizationAction) + ); + } + + #[test] + fn domains_accept_lowercase_ldh_dns_localhost_and_canonical_ipv4() { + for value in [ + "localhost", + "media.example.com", + "xn--bcher-kva.example", + "127.0.0.1", + "0.0.0.0", + ] { + let domain = RadrootsBlossomServerDomain::parse(value).unwrap(); + assert_eq!(domain.as_str(), value); + assert_eq!(domain.to_string(), value); + assert_eq!( + value.parse::<RadrootsBlossomServerDomain>().unwrap(), + domain + ); + } + + let long_label = format!("{}.example", "a".repeat(64)); + let long_domain = "a".repeat(254); + for value in [ + "", + "Media.example.com", + "média.example", + "media_example.com", + "-media.example", + "media-.example", + "media..example", + "media.example.", + "https://media.example", + "media.example:443", + "127.1", + "127.00.0.1", + "256.0.0.1", + "2130706433", + &long_label, + &long_domain, + ] { + assert_eq!( + RadrootsBlossomServerDomain::parse(value), + Err(RadrootsBlossomError::InvalidAuthorizationServerDomain), + "{value}" + ); + } + } + + #[test] + fn content_requires_trimmed_nonempty_control_free_text() { + for value in ["", " Upload Blob", "Upload Blob ", "Upload\nBlob", "\u{7f}"] { + assert_eq!( + RadrootsBlossomAuthorizationContent::parse(value), + Err(RadrootsBlossomError::InvalidAuthorizationContent) + ); + } + let value = RadrootsBlossomAuthorizationContent::parse("Téléverser l'image").unwrap(); + assert_eq!(value.as_str(), "Téléverser l'image"); + assert_eq!(value.to_string(), "Téléverser l'image"); + assert_eq!( + "Upload Blob" + .parse::<RadrootsBlossomAuthorizationContent>() + .unwrap(), + content() + ); + } + + #[test] + fn targets_derive_protocol_actions_hashes_and_requirements() { + let hash = hash(); + let cases = [ + ( + RadrootsBlossomAuthorizationTarget::GetBlob(hash), + RadrootsBlossomAuthorizationAction::Get, + Some(hash), + false, + ), + ( + RadrootsBlossomAuthorizationTarget::Upload(hash), + RadrootsBlossomAuthorizationAction::Upload, + Some(hash), + true, + ), + ( + RadrootsBlossomAuthorizationTarget::List, + RadrootsBlossomAuthorizationAction::List, + None, + false, + ), + ( + RadrootsBlossomAuthorizationTarget::DeleteBlob(hash), + RadrootsBlossomAuthorizationAction::Delete, + Some(hash), + true, + ), + ( + RadrootsBlossomAuthorizationTarget::Mirror(hash), + RadrootsBlossomAuthorizationAction::Upload, + Some(hash), + true, + ), + ( + RadrootsBlossomAuthorizationTarget::Media(hash), + RadrootsBlossomAuthorizationAction::Media, + Some(hash), + true, + ), + ]; + for (target, action, implied_hash, requires_hash_tag) in cases { + assert_eq!(target.action(), action); + assert_eq!(target.implied_hash(), implied_hash); + assert_eq!(target.requires_hash_tag(), requires_hash_tag); + } + } + + #[test] + fn validation_policy_is_bounded_and_exposes_injected_values() { + let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); + let policy = validation( + target, + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ); + assert_eq!(policy.target(), target); + assert_eq!(policy.target_server(), &server()); + assert_eq!( + policy.server_scope_requirement(), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch + ); + assert_eq!(policy.now(), NOW); + assert_eq!(policy.max_created_age_seconds(), 300); + assert!( + RadrootsBlossomAuthorizationValidation::new( + target, + server(), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + NOW, + 0, + ) + .is_ok() + ); + assert_eq!( + RadrootsBlossomAuthorizationValidation::new( + target, + server(), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + NOW, + 301, + ), + Err(RadrootsBlossomError::InvalidAuthorizationCreatedAge) + ); + } + + #[test] + fn tolerant_parser_accepts_multiple_scopes_trailing_fields_and_unknown_tags() { + let mut tags = tags("upload", NOW + 60); + tags[0].push("ignored".to_string()); + tags[1].push("ignored".to_string()); + tags[2].push("ignored".to_string()); + tags[3].push("ignored".to_string()); + tags.push(vec!["server".to_string(), "other.example".to_string()]); + tags.push(vec![ + "x".to_string(), + RadrootsBlossomSha256::digest(b"other").to_hex(), + ]); + tags.push(vec!["unknown".to_string()]); + tags.push(Vec::new()); + + let parsed = + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW - 10, &tags).unwrap(); + assert_eq!(parsed.content(), &content()); + assert_eq!(parsed.created_at(), NOW - 10); + assert_eq!(parsed.action(), RadrootsBlossomAuthorizationAction::Upload); + assert_eq!(parsed.expiration(), NOW + 60); + assert_eq!(parsed.server_domains().len(), 2); + assert_eq!(parsed.hashes().len(), 2); + } + + #[test] + fn tolerant_parser_rejects_missing_duplicate_and_malformed_known_tags() { + let cases = [ + ( + vec![vec!["expiration".to_string(), (NOW + 60).to_string()]], + RadrootsBlossomError::MissingAuthorizationActionTag, + ), + ( + vec![vec!["t".to_string(), "upload".to_string()]], + RadrootsBlossomError::MissingAuthorizationExpirationTag, + ), + ( + vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + ], + RadrootsBlossomError::DuplicateAuthorizationActionTag, + ), + ( + vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + ], + RadrootsBlossomError::DuplicateAuthorizationExpirationTag, + ), + ( + vec![ + vec!["t".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + ], + RadrootsBlossomError::MalformedAuthorizationActionTag, + ), + ( + vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string()], + ], + RadrootsBlossomError::MalformedAuthorizationExpirationTag, + ), + ( + vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + vec!["server".to_string()], + ], + RadrootsBlossomError::MalformedAuthorizationServerTag, + ), + ( + vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + vec!["x".to_string()], + ], + RadrootsBlossomError::MalformedAuthorizationHashTag, + ), + ]; + for (tags, expected) in cases { + assert_eq!( + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &tags), + Err(expected) + ); + } + + for expiration in ["", " 10", "+10", "ten", "18446744073709551616"] { + let tags = vec![ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), expiration.to_string()], + ]; + assert_eq!( + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &tags), + Err(RadrootsBlossomError::MalformedAuthorizationExpirationTag) + ); + } + + let mut invalid = tags("Upload", NOW + 60); + assert_eq!( + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(RadrootsBlossomError::MalformedAuthorizationActionTag) + ); + invalid = tags("upload", NOW + 60); + invalid[3][1] = "Media.example".to_string(); + assert_eq!( + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(RadrootsBlossomError::MalformedAuthorizationServerTag) + ); + invalid = tags("upload", NOW + 60); + invalid[2][1] = "not-a-hash".to_string(); + assert_eq!( + RadrootsBlossomParsedAuthorizationClaim::parse("Upload Blob", NOW, &invalid), + Err(RadrootsBlossomError::MalformedAuthorizationHashTag) + ); + } + + #[test] + fn upload_validation_enforces_time_action_and_required_scopes() { + let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); + let required = validation( + target, + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ); + let validated = parse_upload().validate(&required).unwrap(); + assert_eq!(validated.claim(), &parse_upload()); + assert_eq!(validated.target(), target); + assert_eq!(validated.target_server(), &server()); + assert_eq!(validated.content(), &content()); + assert_eq!(validated.created_at(), NOW - 10); + assert_eq!( + validated.action(), + RadrootsBlossomAuthorizationAction::Upload + ); + assert_eq!(validated.expiration(), NOW + 60); + assert_eq!(validated.server_domains(), &[server()]); + assert_eq!(validated.hashes(), &[hash()]); + + let time_cases = [ + ( + NOW + 1, + NOW + 60, + RadrootsBlossomError::AuthorizationCreatedInFuture, + ), + (NOW - 301, NOW - 1, RadrootsBlossomError::AuthorizationStale), + ( + NOW - 10, + NOW - 10, + RadrootsBlossomError::InvalidAuthorizationLifetime, + ), + ( + NOW - 10, + NOW + 291, + RadrootsBlossomError::InvalidAuthorizationLifetime, + ), + (NOW - 10, NOW, RadrootsBlossomError::AuthorizationExpired), + ]; + for (created_at, expiration, expected) in time_cases { + let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + created_at, + &tags("upload", expiration), + ) + .unwrap(); + assert_eq!(parsed.validate(&required), Err(expected)); + } + + let wrong_action = RadrootsBlossomParsedAuthorizationClaim::parse( + "List Blobs", + NOW - 10, + &tags("list", NOW + 60), + ) + .unwrap(); + assert_eq!( + wrong_action.validate(&required), + Err(RadrootsBlossomError::AuthorizationActionMismatch) + ); + } + + #[test] + fn server_scope_uses_optional_or_required_any_match() { + let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); + let optional = validation( + target, + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + ); + let required = validation( + target, + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + ); + + let no_server = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - 10, + &tags("upload", NOW + 60)[..3], + ) + .unwrap(); + assert!(no_server.validate(&optional).is_ok()); + assert_eq!( + no_server.validate(&required), + Err(RadrootsBlossomError::AuthorizationServerRequired) + ); + + let mut mismatched_tags = tags("upload", NOW + 60); + mismatched_tags[3][1] = "other.example".to_string(); + let mismatch = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - 10, + &mismatched_tags, + ) + .unwrap(); + assert_eq!( + mismatch.validate(&optional), + Err(RadrootsBlossomError::AuthorizationServerMismatch) + ); + + mismatched_tags.push(vec!["server".to_string(), "media.example.com".to_string()]); + let any_match = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - 10, + &mismatched_tags, + ) + .unwrap(); + assert!(any_match.validate(&required).is_ok()); + } + + #[test] + fn hash_scope_follows_each_endpoint_requirement_with_any_match() { + let hash = hash(); + let no_scope = vec![ + vec!["t".to_string(), "get".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + ]; + let get = RadrootsBlossomParsedAuthorizationClaim::parse("Get Blob", NOW - 10, &no_scope) + .unwrap(); + assert!( + get.validate(&validation( + RadrootsBlossomAuthorizationTarget::GetBlob(hash), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + )) + .is_ok() + ); + + let list = RadrootsBlossomParsedAuthorizationClaim::parse( + "List Blobs", + NOW - 10, + &[ + vec!["t".to_string(), "list".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + vec!["x".to_string(), HASH_HEX.to_string()], + ], + ) + .unwrap(); + assert!( + list.validate(&validation( + RadrootsBlossomAuthorizationTarget::List, + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + )) + .is_ok() + ); + + let missing_required = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - 10, + &[ + vec!["t".to_string(), "upload".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + ], + ) + .unwrap(); + assert_eq!( + missing_required.validate(&validation( + RadrootsBlossomAuthorizationTarget::Upload(hash), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + )), + Err(RadrootsBlossomError::AuthorizationHashRequired) + ); + + let wrong_hash = RadrootsBlossomSha256::digest(b"wrong"); + let wrong_scope = RadrootsBlossomParsedAuthorizationClaim::parse( + "Get Blob", + NOW - 10, + &[ + vec!["t".to_string(), "get".to_string()], + vec!["expiration".to_string(), (NOW + 60).to_string()], + vec!["x".to_string(), wrong_hash.to_hex()], + ], + ) + .unwrap(); + assert_eq!( + wrong_scope.validate(&validation( + RadrootsBlossomAuthorizationTarget::GetBlob(hash), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + )), + Err(RadrootsBlossomError::AuthorizationHashMismatch) + ); + + let mut any_tags = tags("delete", NOW + 60); + any_tags[2][1] = wrong_hash.to_hex(); + any_tags.push(vec!["x".to_string(), HASH_HEX.to_string()]); + let any_match = + RadrootsBlossomParsedAuthorizationClaim::parse("Delete Blob", NOW - 10, &any_tags) + .unwrap(); + for target in [ + RadrootsBlossomAuthorizationTarget::DeleteBlob(hash), + RadrootsBlossomAuthorizationTarget::Mirror(hash), + RadrootsBlossomAuthorizationTarget::Media(hash), + ] { + let action = target.action(); + let mut action_tags = any_tags.clone(); + action_tags[0][1] = action.as_str().to_string(); + let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( + "Authorized Operation", + NOW - 10, + &action_tags, + ) + .unwrap(); + assert!( + parsed + .validate(&validation( + target, + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + )) + .is_ok() + ); + } + assert_eq!(any_match.hashes().len(), 2); + } + + #[test] + fn strict_authored_upload_claim_emits_canonical_wire_parts() { + let claim = + RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), NOW, 60).unwrap(); + assert_eq!(claim.content(), &content()); + assert_eq!(claim.server_domain(), &server()); + assert_eq!(claim.sha256(), hash()); + assert_eq!(claim.created_at(), NOW); + assert_eq!(claim.expiration(), NOW + 60); + assert_eq!(claim.lifetime_seconds(), 60); + + let wire = claim.wire_parts(); + assert_eq!(wire.kind(), RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND); + assert_eq!(wire.created_at(), NOW); + assert_eq!(wire.content(), "Upload Blob"); + assert_eq!(wire.tags(), tags("upload", NOW + 60)); + + let (kind, created_at, tags, content) = claim.into_wire_parts().into_parts(); + assert_eq!(kind, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND); + assert_eq!(created_at, NOW); + assert_eq!(tags, self::tags("upload", NOW + 60)); + assert_eq!(content, "Upload Blob"); + } + + #[test] + fn strict_authored_upload_claim_rejects_bad_lifetime_and_overflow() { + for lifetime in [0, 301] { + assert_eq!( + RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), NOW, lifetime,), + Err(RadrootsBlossomError::InvalidAuthorizationLifetime) + ); + } + assert_eq!( + RadrootsBlossomAuthoredUploadClaim::new(content(), server(), hash(), u64::MAX, 1,), + Err(RadrootsBlossomError::AuthorizationTimestampOverflow) + ); + } + + #[test] + fn validation_accepts_exact_time_boundaries() { + let boundary_tags = tags("upload", NOW + 1); + let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW - (RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS - 1), + &boundary_tags, + ) + .unwrap(); + assert!( + parsed + .validate(&validation( + RadrootsBlossomAuthorizationTarget::Upload(hash()), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + )) + .is_ok() + ); + + let zero_age = RadrootsBlossomAuthorizationValidation::new( + RadrootsBlossomAuthorizationTarget::Upload(hash()), + server(), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + NOW, + 0, + ) + .unwrap(); + let created_now = RadrootsBlossomParsedAuthorizationClaim::parse( + "Upload Blob", + NOW, + &tags("upload", NOW + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS), + ) + .unwrap(); + assert!(created_now.validate(&zero_age).is_ok()); + } +} diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -18,6 +18,28 @@ pub enum RadrootsBlossomError { BlobHashMismatch, BlobSizeMismatch { expected: u64, actual: u64 }, BlobMediaTypeMismatch, + InvalidAuthorizationContent, + InvalidAuthorizationAction, + InvalidAuthorizationServerDomain, + MissingAuthorizationActionTag, + DuplicateAuthorizationActionTag, + MalformedAuthorizationActionTag, + MissingAuthorizationExpirationTag, + DuplicateAuthorizationExpirationTag, + MalformedAuthorizationExpirationTag, + MalformedAuthorizationServerTag, + MalformedAuthorizationHashTag, + InvalidAuthorizationCreatedAge, + InvalidAuthorizationLifetime, + AuthorizationTimestampOverflow, + AuthorizationCreatedInFuture, + AuthorizationStale, + AuthorizationExpired, + AuthorizationActionMismatch, + AuthorizationServerRequired, + AuthorizationServerMismatch, + AuthorizationHashRequired, + AuthorizationHashMismatch, } impl RadrootsBlossomError { @@ -38,6 +60,28 @@ impl RadrootsBlossomError { Self::BlobHashMismatch => "blob_hash_mismatch", Self::BlobSizeMismatch { .. } => "blob_size_mismatch", Self::BlobMediaTypeMismatch => "blob_media_type_mismatch", + Self::InvalidAuthorizationContent => "invalid_authorization_content", + Self::InvalidAuthorizationAction => "invalid_authorization_action", + Self::InvalidAuthorizationServerDomain => "invalid_authorization_server_domain", + Self::MissingAuthorizationActionTag => "missing_authorization_action_tag", + Self::DuplicateAuthorizationActionTag => "duplicate_authorization_action_tag", + Self::MalformedAuthorizationActionTag => "malformed_authorization_action_tag", + Self::MissingAuthorizationExpirationTag => "missing_authorization_expiration_tag", + Self::DuplicateAuthorizationExpirationTag => "duplicate_authorization_expiration_tag", + Self::MalformedAuthorizationExpirationTag => "malformed_authorization_expiration_tag", + Self::MalformedAuthorizationServerTag => "malformed_authorization_server_tag", + Self::MalformedAuthorizationHashTag => "malformed_authorization_hash_tag", + Self::InvalidAuthorizationCreatedAge => "invalid_authorization_created_age", + Self::InvalidAuthorizationLifetime => "invalid_authorization_lifetime", + Self::AuthorizationTimestampOverflow => "authorization_timestamp_overflow", + Self::AuthorizationCreatedInFuture => "authorization_created_in_future", + Self::AuthorizationStale => "authorization_stale", + Self::AuthorizationExpired => "authorization_expired", + Self::AuthorizationActionMismatch => "authorization_action_mismatch", + Self::AuthorizationServerRequired => "authorization_server_required", + Self::AuthorizationServerMismatch => "authorization_server_mismatch", + Self::AuthorizationHashRequired => "authorization_hash_required", + Self::AuthorizationHashMismatch => "authorization_hash_mismatch", } } } @@ -76,6 +120,68 @@ impl fmt::Display for RadrootsBlossomError { Self::BlobMediaTypeMismatch => { f.write_str("descriptor media type does not match the approved media type") } + Self::InvalidAuthorizationContent => { + f.write_str("Blossom authorization content must be trimmed human-readable text") + } + Self::InvalidAuthorizationAction => f.write_str("invalid Blossom authorization action"), + Self::InvalidAuthorizationServerDomain => { + f.write_str("invalid Blossom authorization server domain") + } + Self::MissingAuthorizationActionTag => { + f.write_str("Blossom authorization is missing a t action tag") + } + Self::DuplicateAuthorizationActionTag => { + f.write_str("Blossom authorization has more than one t action tag") + } + Self::MalformedAuthorizationActionTag => { + f.write_str("malformed Blossom authorization t action tag") + } + Self::MissingAuthorizationExpirationTag => { + f.write_str("Blossom authorization is missing an expiration tag") + } + Self::DuplicateAuthorizationExpirationTag => { + f.write_str("Blossom authorization has more than one expiration tag") + } + Self::MalformedAuthorizationExpirationTag => { + f.write_str("malformed Blossom authorization expiration tag") + } + Self::MalformedAuthorizationServerTag => { + f.write_str("malformed Blossom authorization server tag") + } + Self::MalformedAuthorizationHashTag => { + f.write_str("malformed Blossom authorization x hash tag") + } + Self::InvalidAuthorizationCreatedAge => { + f.write_str("Blossom authorization maximum created age must not exceed 300 seconds") + } + Self::InvalidAuthorizationLifetime => { + f.write_str("Blossom authorization lifetime must be between 1 and 300 seconds") + } + Self::AuthorizationTimestampOverflow => { + f.write_str("Blossom authorization expiration timestamp overflows u64") + } + Self::AuthorizationCreatedInFuture => { + f.write_str("Blossom authorization was created in the future") + } + Self::AuthorizationStale => { + f.write_str("Blossom authorization is outside the accepted creation-age window") + } + Self::AuthorizationExpired => f.write_str("Blossom authorization is expired"), + Self::AuthorizationActionMismatch => { + f.write_str("Blossom authorization action does not match the target endpoint") + } + Self::AuthorizationServerRequired => { + f.write_str("Blossom authorization requires a server scope") + } + Self::AuthorizationServerMismatch => { + f.write_str("Blossom authorization does not include the target server") + } + Self::AuthorizationHashRequired => { + f.write_str("Blossom authorization requires an x hash scope") + } + Self::AuthorizationHashMismatch => { + f.write_str("Blossom authorization does not include the target blob hash") + } } } } @@ -109,10 +215,152 @@ mod tests { actual: 2, }, RadrootsBlossomError::BlobMediaTypeMismatch, + RadrootsBlossomError::InvalidAuthorizationContent, + RadrootsBlossomError::InvalidAuthorizationAction, + RadrootsBlossomError::InvalidAuthorizationServerDomain, + RadrootsBlossomError::MissingAuthorizationActionTag, + RadrootsBlossomError::DuplicateAuthorizationActionTag, + RadrootsBlossomError::MalformedAuthorizationActionTag, + RadrootsBlossomError::MissingAuthorizationExpirationTag, + RadrootsBlossomError::DuplicateAuthorizationExpirationTag, + RadrootsBlossomError::MalformedAuthorizationExpirationTag, + RadrootsBlossomError::MalformedAuthorizationServerTag, + RadrootsBlossomError::MalformedAuthorizationHashTag, + RadrootsBlossomError::InvalidAuthorizationCreatedAge, + RadrootsBlossomError::InvalidAuthorizationLifetime, + RadrootsBlossomError::AuthorizationTimestampOverflow, + RadrootsBlossomError::AuthorizationCreatedInFuture, + RadrootsBlossomError::AuthorizationStale, + RadrootsBlossomError::AuthorizationExpired, + RadrootsBlossomError::AuthorizationActionMismatch, + RadrootsBlossomError::AuthorizationServerRequired, + RadrootsBlossomError::AuthorizationServerMismatch, + RadrootsBlossomError::AuthorizationHashRequired, + RadrootsBlossomError::AuthorizationHashMismatch, ]; for error in errors { assert!(!error.code().is_empty()); assert!(!format!("{error}").is_empty()); } } + + #[test] + fn authorization_error_codes_and_messages_are_stable() { + let cases = [ + ( + RadrootsBlossomError::InvalidAuthorizationContent, + "invalid_authorization_content", + "Blossom authorization content must be trimmed human-readable text", + ), + ( + RadrootsBlossomError::InvalidAuthorizationAction, + "invalid_authorization_action", + "invalid Blossom authorization action", + ), + ( + RadrootsBlossomError::InvalidAuthorizationServerDomain, + "invalid_authorization_server_domain", + "invalid Blossom authorization server domain", + ), + ( + RadrootsBlossomError::MissingAuthorizationActionTag, + "missing_authorization_action_tag", + "Blossom authorization is missing a t action tag", + ), + ( + RadrootsBlossomError::DuplicateAuthorizationActionTag, + "duplicate_authorization_action_tag", + "Blossom authorization has more than one t action tag", + ), + ( + RadrootsBlossomError::MalformedAuthorizationActionTag, + "malformed_authorization_action_tag", + "malformed Blossom authorization t action tag", + ), + ( + RadrootsBlossomError::MissingAuthorizationExpirationTag, + "missing_authorization_expiration_tag", + "Blossom authorization is missing an expiration tag", + ), + ( + RadrootsBlossomError::DuplicateAuthorizationExpirationTag, + "duplicate_authorization_expiration_tag", + "Blossom authorization has more than one expiration tag", + ), + ( + RadrootsBlossomError::MalformedAuthorizationExpirationTag, + "malformed_authorization_expiration_tag", + "malformed Blossom authorization expiration tag", + ), + ( + RadrootsBlossomError::MalformedAuthorizationServerTag, + "malformed_authorization_server_tag", + "malformed Blossom authorization server tag", + ), + ( + RadrootsBlossomError::MalformedAuthorizationHashTag, + "malformed_authorization_hash_tag", + "malformed Blossom authorization x hash tag", + ), + ( + RadrootsBlossomError::InvalidAuthorizationCreatedAge, + "invalid_authorization_created_age", + "Blossom authorization maximum created age must not exceed 300 seconds", + ), + ( + RadrootsBlossomError::InvalidAuthorizationLifetime, + "invalid_authorization_lifetime", + "Blossom authorization lifetime must be between 1 and 300 seconds", + ), + ( + RadrootsBlossomError::AuthorizationTimestampOverflow, + "authorization_timestamp_overflow", + "Blossom authorization expiration timestamp overflows u64", + ), + ( + RadrootsBlossomError::AuthorizationCreatedInFuture, + "authorization_created_in_future", + "Blossom authorization was created in the future", + ), + ( + RadrootsBlossomError::AuthorizationStale, + "authorization_stale", + "Blossom authorization is outside the accepted creation-age window", + ), + ( + RadrootsBlossomError::AuthorizationExpired, + "authorization_expired", + "Blossom authorization is expired", + ), + ( + RadrootsBlossomError::AuthorizationActionMismatch, + "authorization_action_mismatch", + "Blossom authorization action does not match the target endpoint", + ), + ( + RadrootsBlossomError::AuthorizationServerRequired, + "authorization_server_required", + "Blossom authorization requires a server scope", + ), + ( + RadrootsBlossomError::AuthorizationServerMismatch, + "authorization_server_mismatch", + "Blossom authorization does not include the target server", + ), + ( + RadrootsBlossomError::AuthorizationHashRequired, + "authorization_hash_required", + "Blossom authorization requires an x hash scope", + ), + ( + RadrootsBlossomError::AuthorizationHashMismatch, + "authorization_hash_mismatch", + "Blossom authorization does not include the target blob hash", + ), + ]; + for (error, code, message) in cases { + assert_eq!(error.code(), code); + assert_eq!(format!("{error}"), message); + } + } } diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -4,11 +4,21 @@ extern crate alloc; +pub mod authorization; pub mod descriptor; pub mod error; pub mod hash; pub mod url; +pub use authorization::{ + RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, + RadrootsBlossomAuthorizationAction, RadrootsBlossomAuthorizationContent, + RadrootsBlossomAuthorizationTarget, RadrootsBlossomAuthorizationValidation, + RadrootsBlossomAuthorizationWireParts, RadrootsBlossomParsedAuthorizationClaim, + RadrootsBlossomServerDomain, RadrootsBlossomServerScopeRequirement, + RadrootsBlossomValidatedAuthorizationClaim, +}; pub use descriptor::{ RadrootsBlossomApprovedDescriptor, RadrootsBlossomBlobDescriptor, RadrootsBlossomByteCommitment, RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, diff --git a/crates/blossom/tests/bud11_conformance.rs b/crates/blossom/tests/bud11_conformance.rs @@ -0,0 +1,397 @@ +use radroots_blossom::{ + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, + RadrootsBlossomAuthorizationAction, RadrootsBlossomAuthorizationContent, + RadrootsBlossomAuthorizationTarget, RadrootsBlossomAuthorizationValidation, + RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomServerDomain, + RadrootsBlossomServerScopeRequirement, RadrootsBlossomSha256, +}; +use serde::Deserialize; +use serde_json::Value; +use std::{borrow::Cow, fs, path::Path}; + +const PACKAGED_VECTORS: &str = include_str!("fixtures/bud11_claims.v1.json"); +const WORKSPACE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/blossom/bud11_claims.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +#[derive(Debug, Deserialize)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn checked_in_bud11_vectors_execute_against_public_api() { + let vectors = conformance_vectors(); + let suite: Suite = serde_json::from_str(&vectors).expect("BUD-11 vectors must parse"); + assert_eq!(suite.suite, "blossom_bud11_claims"); + assert_eq!(suite.contract_version, "0.1.0"); + assert!(!suite.vectors.is_empty()); + + for vector in &suite.vectors { + execute(vector); + } +} + +fn conformance_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_VECTORS, + "packaged BUD-11 vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute(vector: &Vector) { + match vector.kind.as_str() { + "blossom.bud11.action.parse.valid" => action_parse_valid(vector), + "blossom.bud11.action.parse.invalid" => action_parse_invalid(vector), + "blossom.bud11.server_domain.parse.valid" => server_domain_parse_valid(vector), + "blossom.bud11.server_domain.parse.invalid" => server_domain_parse_invalid(vector), + "blossom.bud11.content.parse.valid" => content_parse_valid(vector), + "blossom.bud11.content.parse.invalid" => content_parse_invalid(vector), + "blossom.bud11.claim.parse.valid" => claim_parse_valid(vector), + "blossom.bud11.claim.parse.invalid" => claim_parse_invalid(vector), + "blossom.bud11.validation.new.valid" => validation_new_valid(vector), + "blossom.bud11.validation.new.invalid" => validation_new_invalid(vector), + "blossom.bud11.claim.validate.valid" => claim_validate_valid(vector), + "blossom.bud11.claim.validate.invalid" => claim_validate_invalid(vector), + "blossom.bud11.authored_upload.valid" => authored_upload_valid(vector), + "blossom.bud11.authored_upload.invalid" => authored_upload_invalid(vector), + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } +} + +fn action_parse_valid(vector: &Vector) { + let action = RadrootsBlossomAuthorizationAction::parse(input_str(vector, "action")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + action.as_str(), + expected_str(vector, "action"), + "{}", + vector.id + ); + assert_eq!( + action.to_string(), + expected_str(vector, "action"), + "{}", + vector.id + ); +} + +fn action_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomAuthorizationAction::parse(input_str(vector, "action")) + .expect_err("invalid action vector must fail"); + assert_error(vector, error.code()); +} + +fn server_domain_parse_valid(vector: &Vector) { + let domain = RadrootsBlossomServerDomain::parse(input_str(vector, "domain")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + domain.as_str(), + expected_str(vector, "domain"), + "{}", + vector.id + ); + assert_eq!( + domain.to_string(), + expected_str(vector, "domain"), + "{}", + vector.id + ); +} + +fn server_domain_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomServerDomain::parse(input_str(vector, "domain")) + .expect_err("invalid server-domain vector must fail"); + assert_error(vector, error.code()); +} + +fn content_parse_valid(vector: &Vector) { + let content = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + content.as_str(), + expected_str(vector, "content"), + "{}", + vector.id + ); + assert_eq!( + content.to_string(), + expected_str(vector, "content"), + "{}", + vector.id + ); +} + +fn content_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content")) + .expect_err("invalid content vector must fail"); + assert_error(vector, error.code()); +} + +fn claim_parse_valid(vector: &Vector) { + let claim = parse_claim(&vector.input, &vector.id); + assert_claim(vector, &claim); +} + +fn claim_parse_invalid(vector: &Vector) { + let error = try_parse_claim(&vector.input).expect_err("invalid claim vector must fail"); + assert_error(vector, error.code()); +} + +fn validation_new_valid(vector: &Vector) { + let validation = + build_validation(vector).unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + validation.max_created_age_seconds(), + expected_u64(vector, "max_created_age"), + "{}", + vector.id + ); +} + +fn validation_new_invalid(vector: &Vector) { + let error = build_validation(vector).expect_err("invalid validation-policy vector must fail"); + assert_error(vector, error.code()); +} + +fn claim_validate_valid(vector: &Vector) { + let claim = parse_claim(&vector.input["claim"], &vector.id); + let validation = build_validation(vector) + .unwrap_or_else(|error| panic!("{} policy failed: {error}", vector.id)); + let validated = claim + .validate(&validation) + .unwrap_or_else(|error| panic!("{} validation failed: {error}", vector.id)); + assert_eq!( + validated.action().as_str(), + expected_str(vector, "action"), + "{}", + vector.id + ); +} + +fn claim_validate_invalid(vector: &Vector) { + let claim = parse_claim(&vector.input["claim"], &vector.id); + let validation = build_validation(vector) + .unwrap_or_else(|error| panic!("{} policy failed: {error}", vector.id)); + let error = claim + .validate(&validation) + .expect_err("invalid claim-validation vector must fail"); + assert_error(vector, error.code()); +} + +fn authored_upload_valid(vector: &Vector) { + let authored = + authored_upload(vector).unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let wire = authored.wire_parts(); + assert_eq!( + wire.kind(), + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + "{}", + vector.id + ); + assert_eq!( + wire.kind() as u64, + expected_u64(vector, "kind"), + "{}", + vector.id + ); + assert_eq!( + wire.content(), + expected_str(vector, "content"), + "{}", + vector.id + ); + assert_eq!( + wire.created_at(), + expected_u64(vector, "created_at"), + "{}", + vector.id + ); + assert_eq!( + authored.expiration(), + expected_u64(vector, "expiration"), + "{}", + vector.id + ); + assert_eq!( + serde_json::to_value(wire.tags()).expect("wire tags must serialize"), + vector.expected["tags"], + "{}", + vector.id + ); +} + +fn authored_upload_invalid(vector: &Vector) { + let error = authored_upload(vector).expect_err("invalid authored-upload vector must fail"); + assert_error(vector, error.code()); +} + +fn authored_upload( + vector: &Vector, +) -> Result<RadrootsBlossomAuthoredUploadClaim, radroots_blossom::RadrootsBlossomError> { + let content = RadrootsBlossomAuthorizationContent::parse(input_str(vector, "content"))?; + let server = RadrootsBlossomServerDomain::parse(input_str(vector, "server_domain"))?; + let hash = RadrootsBlossomSha256::from_hex(input_str(vector, "hash"))?; + RadrootsBlossomAuthoredUploadClaim::new( + content, + server, + hash, + input_u64(vector, "created_at"), + input_u64(vector, "lifetime"), + ) +} + +fn try_parse_claim( + input: &Value, +) -> Result<RadrootsBlossomParsedAuthorizationClaim, radroots_blossom::RadrootsBlossomError> { + let tags: Vec<Vec<String>> = + serde_json::from_value(input["tags"].clone()).expect("claim tags must be string arrays"); + RadrootsBlossomParsedAuthorizationClaim::parse( + value_str(input, "content"), + value_u64(input, "created_at"), + &tags, + ) +} + +fn parse_claim(input: &Value, id: &str) -> RadrootsBlossomParsedAuthorizationClaim { + try_parse_claim(input).unwrap_or_else(|error| panic!("{id} claim parse failed: {error}")) +} + +fn assert_claim(vector: &Vector, claim: &RadrootsBlossomParsedAuthorizationClaim) { + assert_eq!( + claim.content().as_str(), + expected_str(vector, "content"), + "{}", + vector.id + ); + assert_eq!( + claim.created_at(), + expected_u64(vector, "created_at"), + "{}", + vector.id + ); + assert_eq!( + claim.action().as_str(), + expected_str(vector, "action"), + "{}", + vector.id + ); + assert_eq!( + claim.expiration(), + expected_u64(vector, "expiration"), + "{}", + vector.id + ); + let servers: Vec<String> = claim + .server_domains() + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + servers, + expected_strings(vector, "servers"), + "{}", + vector.id + ); + let hashes: Vec<String> = claim.hashes().iter().map(ToString::to_string).collect(); + assert_eq!(hashes, expected_strings(vector, "hashes"), "{}", vector.id); +} + +fn build_validation( + vector: &Vector, +) -> Result<RadrootsBlossomAuthorizationValidation, radroots_blossom::RadrootsBlossomError> { + let target = parse_target(&vector.input["target"]); + let server = RadrootsBlossomServerDomain::parse(input_str(vector, "server_domain"))?; + let server_scope = match input_str(vector, "server_scope") { + "optional_any_match" => RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + "required_any_match" => RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + value => panic!("{} has unsupported server scope {value}", vector.id), + }; + RadrootsBlossomAuthorizationValidation::new( + target, + server, + server_scope, + input_u64(vector, "now"), + input_u64(vector, "max_created_age"), + ) +} + +fn parse_target(input: &Value) -> RadrootsBlossomAuthorizationTarget { + let hash = || { + RadrootsBlossomSha256::from_hex(value_str(input, "hash")) + .expect("target hash must be valid") + }; + match value_str(input, "type") { + "get_blob" => RadrootsBlossomAuthorizationTarget::GetBlob(hash()), + "upload" => RadrootsBlossomAuthorizationTarget::Upload(hash()), + "list" => RadrootsBlossomAuthorizationTarget::List, + "delete_blob" => RadrootsBlossomAuthorizationTarget::DeleteBlob(hash()), + "mirror" => RadrootsBlossomAuthorizationTarget::Mirror(hash()), + "media" => RadrootsBlossomAuthorizationTarget::Media(hash()), + value => panic!("unsupported BUD-11 target {value}"), + } +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + value_str(&vector.input, field) +} + +fn input_u64(vector: &Vector, field: &str) -> u64 { + value_u64(&vector.input, field) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + value_str(&vector.expected, field) +} + +fn expected_u64(vector: &Vector, field: &str) -> u64 { + value_u64(&vector.expected, field) +} + +fn expected_strings(vector: &Vector, field: &str) -> Vec<String> { + serde_json::from_value(vector.expected[field].clone()) + .unwrap_or_else(|error| panic!("{} expected {field} is invalid: {error}", vector.id)) +} + +fn value_str<'a>(value: &'a Value, field: &str) -> &'a str { + value[field] + .as_str() + .unwrap_or_else(|| panic!("{field} must be a string")) +} + +fn value_u64(value: &Value, field: &str) -> u64 { + value[field] + .as_u64() + .unwrap_or_else(|| panic!("{field} must be an unsigned integer")) +} + +fn assert_error(vector: &Vector, actual: &str) { + assert_eq!(actual, expected_str(vector, "error"), "{}", vector.id); +} diff --git a/crates/blossom/tests/fixtures/bud11_claims.v1.json b/crates/blossom/tests/fixtures/bud11_claims.v1.json @@ -0,0 +1,964 @@ +{ + "suite": "blossom_bud11_claims", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "action-get", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "get" }, + "expected": { "action": "get" } + }, + { + "id": "action-upload", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "upload" }, + "expected": { "action": "upload" } + }, + { + "id": "action-list", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "list" }, + "expected": { "action": "list" } + }, + { + "id": "action-delete", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "delete" }, + "expected": { "action": "delete" } + }, + { + "id": "action-media", + "kind": "blossom.bud11.action.parse.valid", + "input": { "action": "media" }, + "expected": { "action": "media" } + }, + { + "id": "action-empty", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-uppercase", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "Upload" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-leading-space", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": " upload" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-trailing-space", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "upload " }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "action-unknown", + "kind": "blossom.bud11.action.parse.invalid", + "input": { "action": "mirror" }, + "expected": { "error": "invalid_authorization_action" } + }, + { + "id": "domain-basic", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "blossom.radroots.test" }, + "expected": { "domain": "blossom.radroots.test" } + }, + { + "id": "domain-localhost", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "localhost" }, + "expected": { "domain": "localhost" } + }, + { + "id": "domain-hyphen-and-digit", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "blob-2.example.test" }, + "expected": { "domain": "blob-2.example.test" } + }, + { + "id": "domain-canonical-ipv4", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "127.0.0.1" }, + "expected": { "domain": "127.0.0.1" } + }, + { + "id": "domain-max-label", + "kind": "blossom.bud11.server_domain.parse.valid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" }, + "expected": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" } + }, + { + "id": "domain-empty", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-uppercase", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "Blossom.Radroots.Test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-scheme", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "https://blossom.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-port", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test:443" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-path", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test/upload" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-trailing-dot", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom.radroots.test." }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-empty-label", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom..test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-leading-hyphen", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "-blossom.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-trailing-hyphen", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom-.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-underscore", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "blossom_server.radroots.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-label-too-long", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.test" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-short-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "127.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-leading-zero-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "127.00.0.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "domain-out-of-range-ipv4", + "kind": "blossom.bud11.server_domain.parse.invalid", + "input": { "domain": "256.0.0.1" }, + "expected": { "error": "invalid_authorization_server_domain" } + }, + { + "id": "content-upload", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Upload farm photo" }, + "expected": { "content": "Upload farm photo" } + }, + { + "id": "content-delete", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Delete old farm photo" }, + "expected": { "content": "Delete old farm photo" } + }, + { + "id": "content-unicode", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Téléverser la photo" }, + "expected": { "content": "Téléverser la photo" } + }, + { + "id": "content-punctuation", + "kind": "blossom.bud11.content.parse.valid", + "input": { "content": "Upload: photo #2!" }, + "expected": { "content": "Upload: photo #2!" } + }, + { + "id": "content-empty", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-space-only", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": " " }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-leading-space", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": " Upload farm photo" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-trailing-space", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload farm photo " }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-newline", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\nphoto" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-tab", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\tphoto" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "content-control", + "kind": "blossom.bud11.content.parse.invalid", + "input": { "content": "Upload\u0000photo" }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "claim-parse-upload", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + }, + "expected": { + "content": "Upload farm photo", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["blossom.radroots.test"], + "hashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + }, + { + "id": "claim-parse-unknown-tags-tolerated", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "List farm photos", + "created_at": 1999999999, + "tags": [ + ["client", "radroots", "future", "fields"], + ["t", "list"], + ["expiration", "2000000001"], + ["future-only"], + [] + ] + }, + "expected": { + "content": "List farm photos", + "created_at": 1999999999, + "action": "list", + "expiration": 2000000001, + "servers": [], + "hashes": [] + } + }, + { + "id": "claim-parse-known-trailing-elements-tolerated", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload", "ignored"], + ["expiration", "2000000060", "ignored", "again"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "ignored"], + ["server", "blossom.radroots.test", "ignored"] + ] + }, + "expected": { + "content": "Upload farm photo", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["blossom.radroots.test"], + "hashes": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + } + }, + { + "id": "claim-parse-multiple-scopes", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Upload selected farm photos", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["server", "other.example.test"], + ["server", "blossom.radroots.test"], + ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + ] + }, + "expected": { + "content": "Upload selected farm photos", + "created_at": 1999999990, + "action": "upload", + "expiration": 2000000060, + "servers": ["other.example.test", "blossom.radroots.test"], + "hashes": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "id": "claim-parse-uppercase-tag-names-unknown", + "kind": "blossom.bud11.claim.parse.valid", + "input": { + "content": "Get farm photo", + "created_at": 1999999999, + "tags": [ + ["T", "delete"], + ["Expiration", "1"], + ["Server", "BAD"], + ["X", "BAD"], + ["t", "get"], + ["expiration", "2000000001"] + ] + }, + "expected": { + "content": "Get farm photo", + "created_at": 1999999999, + "action": "get", + "expiration": 2000000001, + "servers": [], + "hashes": [] + } + }, + { + "id": "claim-parse-invalid-content", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"]] + }, + "expected": { "error": "invalid_authorization_content" } + }, + { + "id": "claim-parse-missing-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["expiration", "2000000060"]] + }, + "expected": { "error": "missing_authorization_action_tag" } + }, + { + "id": "claim-parse-duplicate-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["t", "upload"], ["expiration", "2000000060"]] + }, + "expected": { "error": "duplicate_authorization_action_tag" } + }, + { + "id": "claim-parse-conflicting-action", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["t", "delete"], ["expiration", "2000000060"]] + }, + "expected": { "error": "duplicate_authorization_action_tag" } + }, + { + "id": "claim-parse-action-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t"], ["expiration", "2000000060"]] + }, + "expected": { "error": "malformed_authorization_action_tag" } + }, + { + "id": "claim-parse-action-invalid-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "UPLOAD"], ["expiration", "2000000060"]] + }, + "expected": { "error": "malformed_authorization_action_tag" } + }, + { + "id": "claim-parse-missing-expiration", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"]] + }, + "expected": { "error": "missing_authorization_expiration_tag" } + }, + { + "id": "claim-parse-duplicate-expiration", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["expiration", "2000000061"]] + }, + "expected": { "error": "duplicate_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-nondigit", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "later"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-negative", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "-1"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-expiration-overflow", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "18446744073709551616"]] + }, + "expected": { "error": "malformed_authorization_expiration_tag" } + }, + { + "id": "claim-parse-server-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["server"]] + }, + "expected": { "error": "malformed_authorization_server_tag" } + }, + { + "id": "claim-parse-server-malformed-before-match", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["server", "https://bad.example"], + ["server", "blossom.radroots.test"] + ] + }, + "expected": { "error": "malformed_authorization_server_tag" } + }, + { + "id": "claim-parse-hash-missing-value", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [["t", "upload"], ["expiration", "2000000060"], ["x"]] + }, + "expected": { "error": "malformed_authorization_hash_tag" } + }, + { + "id": "claim-parse-hash-malformed-before-match", + "kind": "blossom.bud11.claim.parse.invalid", + "input": { + "content": "Upload farm photo", + "created_at": 1999999990, + "tags": [ + ["t", "upload"], + ["expiration", "2000000060"], + ["x", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + ] + }, + "expected": { "error": "malformed_authorization_hash_tag" } + }, + { + "id": "validation-max-age-zero", + "kind": "blossom.bud11.validation.new.valid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 0 + }, + "expected": { "max_created_age": 0 } + }, + { + "id": "validation-max-age-300", + "kind": "blossom.bud11.validation.new.valid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "max_created_age": 300 } + }, + { + "id": "validation-max-age-301", + "kind": "blossom.bud11.validation.new.invalid", + "input": { + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 301 + }, + "expected": { "error": "invalid_authorization_created_age" } + }, + { + "id": "validate-get-no-hash-scope", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Get farm photo", "created_at": 2000000000, "tags": [["t", "get"], ["expiration", "2000000001"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "get" } + }, + { + "id": "validate-get-multiple-hash-any-match", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Get selected farm photo", "created_at": 1999999990, "tags": [["t", "get"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "get" } + }, + { + "id": "validate-upload", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-list", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-delete", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Delete farm photo", "created_at": 1999999990, "tags": [["t", "delete"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "delete_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "delete" } + }, + { + "id": "validate-mirror-uses-upload", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Mirror farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "mirror", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-media", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Transform farm photo", "created_at": 1999999990, "tags": [["t", "media"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "media", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "media" } + }, + { + "id": "validate-list-ignores-valid-hash-scopes", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-optional-server-absent", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-multiple-server-any-match", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], ["server", "other.example.test"], ["server", "blossom.radroots.test"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-multiple-hash-any-match-required", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "Upload selected farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "upload" } + }, + { + "id": "validate-created-at-now", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 0 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-horizon-300", + "kind": "blossom.bud11.claim.validate.valid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999701, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "action": "list" } + }, + { + "id": "validate-action-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]] }, + "target": { "type": "delete_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_action_mismatch" } + }, + { + "id": "validate-optional-server-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "other.example.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_mismatch" } + }, + { + "id": "validate-required-server-missing", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_required" } + }, + { + "id": "validate-required-server-multiple-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000060"], ["server", "one.example.test"], ["server", "two.example.test"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "required_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_server_mismatch" } + }, + { + "id": "validate-required-hash-missing", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_required" } + }, + { + "id": "validate-required-hash-multiple-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Upload selected farm photo", "created_at": 1999999990, "tags": [["t", "upload"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], ["x", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]] }, + "target": { "type": "upload", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_mismatch" } + }, + { + "id": "validate-get-optional-hash-mismatch", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "Get selected farm photo", "created_at": 1999999990, "tags": [["t", "get"], ["expiration", "2000000060"], ["x", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]] }, + "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_hash_mismatch" } + }, + { + "id": "validate-created-at-future", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000001, "tags": [["t", "list"], ["expiration", "2000000002"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_created_in_future" } + }, + { + "id": "validate-created-at-stale-301", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999699, "tags": [["t", "list"], ["expiration", "2000000001"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_stale" } + }, + { + "id": "validate-expiration-at-now", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 1999999990, "tags": [["t", "list"], ["expiration", "2000000000"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "authorization_expired" } + }, + { + "id": "validate-horizon-301", + "kind": "blossom.bud11.claim.validate.invalid", + "input": { + "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000301"]] }, + "target": { "type": "list" }, + "server_domain": "blossom.radroots.test", + "server_scope": "optional_any_match", + "now": 2000000000, + "max_created_age": 300 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-lifetime-1", + "kind": "blossom.bud11.authored_upload.valid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 1 + }, + "expected": { + "kind": 24242, + "content": "Upload farm photo", + "created_at": 2000000000, + "expiration": 2000000001, + "tags": [ + ["t", "upload"], + ["expiration", "2000000001"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + } + }, + { + "id": "authored-upload-lifetime-300", + "kind": "blossom.bud11.authored_upload.valid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 300 + }, + "expected": { + "kind": 24242, + "content": "Upload farm photo", + "created_at": 2000000000, + "expiration": 2000000300, + "tags": [ + ["t", "upload"], + ["expiration", "2000000300"], + ["x", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ["server", "blossom.radroots.test"] + ] + } + }, + { + "id": "authored-upload-lifetime-zero", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 0 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-lifetime-301", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 2000000000, + "lifetime": 301 + }, + "expected": { "error": "invalid_authorization_lifetime" } + }, + { + "id": "authored-upload-expiration-overflow", + "kind": "blossom.bud11.authored_upload.invalid", + "input": { + "content": "Upload farm photo", + "server_domain": "blossom.radroots.test", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": 18446744073709551615, + "lifetime": 1 + }, + "expected": { "error": "authorization_timestamp_overflow" } + } + ] +} diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -15,6 +15,7 @@ readme = "README" [features] default = ["std"] std = [] +blossom = ["std", "dep:base64", "dep:radroots_blossom"] client = ["std", "dep:nostr-sdk", "dep:radroots_identity"] codec = [ "dep:radroots_event", @@ -32,6 +33,8 @@ http = ["dep:reqwest"] nip17 = ["std", "codec", "nostr/nip44", "nostr/nip59"] [dependencies] +base64 = { workspace = true, optional = true } +radroots_blossom = { workspace = true, optional = true, default-features = false } radroots_event = { workspace = true, optional = true, default-features = false } radroots_event_codec = { workspace = true, optional = true, default-features = false } radroots_identity = { workspace = true, optional = true, default-features = false, features = [ diff --git a/crates/nostr/README b/crates/nostr/README @@ -10,8 +10,15 @@ primitives for the `radroots` core libraries. network use; * adapters between `radroots_event`, `radroots_event_codec`, and Nostr wire representations; + * strict BUD-11 signed HTTP authorization adapters behind the `blossom` + feature; * optional NIP-11 and NIP-17 support alongside `std` and `no_std` builds. +The `blossom` feature signs kind-24242 authorization events and encodes or +verifies their `Authorization: Nostr` HTTP values. It does not publish these +ephemeral authorization events to relays. Pure BUD-11 claim parsing and policy +validation remain in `radroots_blossom`. + ## Portable relay-client lifecycle With the `client` feature, callers can subscribe and publish to selected relay diff --git a/crates/nostr/src/blossom.rs b/crates/nostr/src/blossom.rs @@ -0,0 +1,370 @@ +//! Signed BUD-11 HTTP authorization adapters. + +use core::fmt; + +use alloc::{string::String, vec::Vec}; +use base64::{ + Engine as _, alphabet, + engine::general_purpose::{GeneralPurpose, NO_PAD, URL_SAFE_NO_PAD}, +}; +use radroots_blossom::{ + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, + RadrootsBlossomAuthorizationValidation, RadrootsBlossomError, + RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomValidatedAuthorizationClaim, +}; + +use crate::types::{ + RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrEventId, RadrootsNostrKeys, + RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTagKind, + RadrootsNostrTimestamp, +}; + +const AUTHORIZATION_SCHEME: &str = "Nostr "; +const PERMISSIVE_URL_SAFE_NO_PAD: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + NO_PAD.with_decode_allow_trailing_bits(true), +); + +/// A kind-24242 event minted from a strict authored Blossom upload claim. +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsNostrSignedBlossomAuthorization { + event: RadrootsNostrEvent, +} + +impl fmt::Debug for RadrootsNostrSignedBlossomAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsNostrSignedBlossomAuthorization") + .field("event_id", &self.event.id) + .field("author", &self.event.pubkey) + .field("created_at", &self.event.created_at) + .finish_non_exhaustive() + } +} + +impl RadrootsNostrSignedBlossomAuthorization { + pub fn event_id(&self) -> RadrootsNostrEventId { + self.event.id + } + + pub fn author(&self) -> RadrootsNostrPublicKey { + self.event.pubkey + } + + pub fn created_at(&self) -> RadrootsNostrTimestamp { + self.event.created_at + } +} + +/// A canonical BUD-11 HTTP `Authorization` value. +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsNostrBlossomAuthorizationHeader(String); + +impl RadrootsNostrBlossomAuthorizationHeader { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Debug for RadrootsNostrBlossomAuthorizationHeader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RadrootsNostrBlossomAuthorizationHeader") + .field(&"[REDACTED]") + .finish() + } +} + +impl AsRef<str> for RadrootsNostrBlossomAuthorizationHeader { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// A signature-verified BUD-11 event whose pure claim policy also passed. +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsNostrVerifiedBlossomAuthorization { + event: RadrootsNostrEvent, + claim: RadrootsBlossomValidatedAuthorizationClaim, +} + +impl fmt::Debug for RadrootsNostrVerifiedBlossomAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsNostrVerifiedBlossomAuthorization") + .field("event_id", &self.event.id) + .field("author", &self.event.pubkey) + .field("created_at", &self.event.created_at) + .finish_non_exhaustive() + } +} + +impl RadrootsNostrVerifiedBlossomAuthorization { + pub fn event_id(&self) -> RadrootsNostrEventId { + self.event.id + } + + pub fn author(&self) -> RadrootsNostrPublicKey { + self.event.pubkey + } + + pub fn created_at(&self) -> RadrootsNostrTimestamp { + self.event.created_at + } + + pub fn claim(&self) -> &RadrootsBlossomValidatedAuthorizationClaim { + &self.claim + } +} + +/// Failures at the signed Blossom HTTP authorization boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RadrootsNostrBlossomError { + InvalidHeaderWhitespace, + InvalidHeaderScheme, + EmptyHeaderPayload, + HeaderPaddingForbidden, + InvalidHeaderBase64, + NonCanonicalHeaderBase64, + InvalidHeaderUtf8, + InvalidEventJson, + InvalidEventKind { actual: u64 }, + InvalidEventId, + InvalidEventSignature, + EventSigning, + BlossomClaim(RadrootsBlossomError), +} + +impl RadrootsNostrBlossomError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidHeaderWhitespace => "invalid_header_whitespace", + Self::InvalidHeaderScheme => "invalid_header_scheme", + Self::EmptyHeaderPayload => "empty_header_payload", + Self::HeaderPaddingForbidden => "header_padding_forbidden", + Self::InvalidHeaderBase64 => "invalid_header_base64", + Self::NonCanonicalHeaderBase64 => "noncanonical_header_base64", + Self::InvalidHeaderUtf8 => "invalid_header_utf8", + Self::InvalidEventJson => "invalid_event_json", + Self::InvalidEventKind { .. } => "invalid_event_kind", + Self::InvalidEventId => "invalid_event_id", + Self::InvalidEventSignature => "invalid_event_signature", + Self::EventSigning => "event_signing", + Self::BlossomClaim(error) => error.code(), + } + } + + pub fn blossom_claim_error(&self) -> Option<&RadrootsBlossomError> { + match self { + Self::BlossomClaim(error) => Some(error), + _ => None, + } + } +} + +impl fmt::Display for RadrootsNostrBlossomError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHeaderWhitespace => { + formatter.write_str("invalid whitespace in Blossom authorization header") + } + Self::InvalidHeaderScheme => { + formatter.write_str("Blossom authorization header must use the Nostr scheme") + } + Self::EmptyHeaderPayload => { + formatter.write_str("Blossom authorization header payload is empty") + } + Self::HeaderPaddingForbidden => { + formatter.write_str("Blossom authorization header Base64url padding is forbidden") + } + Self::InvalidHeaderBase64 => { + formatter.write_str("invalid Blossom authorization header Base64url") + } + Self::NonCanonicalHeaderBase64 => { + formatter.write_str("noncanonical Blossom authorization header Base64url") + } + Self::InvalidHeaderUtf8 => { + formatter.write_str("Blossom authorization event JSON is not UTF-8") + } + Self::InvalidEventJson => { + formatter.write_str("invalid Blossom authorization event JSON") + } + Self::InvalidEventKind { actual } => write!( + formatter, + "invalid Blossom authorization event kind {actual}" + ), + Self::InvalidEventId => formatter.write_str("invalid Blossom authorization event id"), + Self::InvalidEventSignature => { + formatter.write_str("invalid Blossom authorization event signature") + } + Self::EventSigning => formatter.write_str("failed to sign Blossom authorization event"), + Self::BlossomClaim(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for RadrootsNostrBlossomError {} + +impl From<RadrootsBlossomError> for RadrootsNostrBlossomError { + fn from(error: RadrootsBlossomError) -> Self { + Self::BlossomClaim(error) + } +} + +/// Sign a direct kind-24242 event from a strict authored BUD-11 upload claim. +pub fn radroots_nostr_sign_blossom_authorization( + keys: &RadrootsNostrKeys, + claim: &RadrootsBlossomAuthoredUploadClaim, +) -> Result<RadrootsNostrSignedBlossomAuthorization, RadrootsNostrBlossomError> { + let wire = claim.wire_parts(); + let tags = wire + .tags() + .iter() + .map(|tag| { + let (kind, values) = tag + .split_first() + .expect("authored Blossom wire tags always contain a kind"); + RadrootsNostrTag::custom( + RadrootsNostrTagKind::custom(kind.as_str()), + values.iter().cloned(), + ) + }) + .collect::<Vec<_>>(); + finish_signed_event( + RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(wire.kind()), wire.content()) + .tags(tags) + .custom_created_at(RadrootsNostrTimestamp::from_secs(wire.created_at())) + .sign_with_keys(keys), + ) +} + +fn finish_signed_event<E>( + result: Result<RadrootsNostrEvent, E>, +) -> Result<RadrootsNostrSignedBlossomAuthorization, RadrootsNostrBlossomError> { + result + .map(|event| RadrootsNostrSignedBlossomAuthorization { event }) + .map_err(|_| RadrootsNostrBlossomError::EventSigning) +} + +/// Encode a signed BUD-11 event as a canonical `Nostr` authorization value. +pub fn radroots_nostr_encode_blossom_authorization_header( + authorization: &RadrootsNostrSignedBlossomAuthorization, +) -> RadrootsNostrBlossomAuthorizationHeader { + // `nostr::Event` contains no fallible serializer fields or non-string map keys. + let json = serde_json::to_vec(&authorization.event) + .expect("Nostr event JSON serialization is infallible"); + let payload = URL_SAFE_NO_PAD.encode(json); + RadrootsNostrBlossomAuthorizationHeader(format!("{AUTHORIZATION_SCHEME}{payload}")) +} + +/// Decode, authenticate, parse, and validate a BUD-11 authorization value. +pub fn radroots_nostr_decode_verify_blossom_authorization_header( + header: &str, + validation: &RadrootsBlossomAuthorizationValidation, +) -> Result<RadrootsNostrVerifiedBlossomAuthorization, RadrootsNostrBlossomError> { + if header.trim_start() != header { + return Err(RadrootsNostrBlossomError::InvalidHeaderWhitespace); + } + let bytes = header.as_bytes(); + let Some(first_space) = bytes.iter().position(|byte| *byte == b' ') else { + return Err(RadrootsNostrBlossomError::InvalidHeaderScheme); + }; + if !bytes[..first_space].eq_ignore_ascii_case(b"Nostr") { + return Err(RadrootsNostrBlossomError::InvalidHeaderScheme); + } + let payload_start = bytes[first_space..] + .iter() + .position(|byte| *byte != b' ') + .map_or(bytes.len(), |offset| first_space + offset); + let payload = &header[payload_start..]; + if payload.is_empty() { + return Err(RadrootsNostrBlossomError::EmptyHeaderPayload); + } + if payload.chars().any(char::is_whitespace) { + return Err(RadrootsNostrBlossomError::InvalidHeaderWhitespace); + } + if payload.contains('=') { + return Err(RadrootsNostrBlossomError::HeaderPaddingForbidden); + } + let decoded = PERMISSIVE_URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| RadrootsNostrBlossomError::InvalidHeaderBase64)?; + if URL_SAFE_NO_PAD.encode(&decoded) != payload { + return Err(RadrootsNostrBlossomError::NonCanonicalHeaderBase64); + } + let json = + String::from_utf8(decoded).map_err(|_| RadrootsNostrBlossomError::InvalidHeaderUtf8)?; + validate_raw_event_json(&json)?; + let event: RadrootsNostrEvent = + serde_json::from_str(&json).map_err(|_| RadrootsNostrBlossomError::InvalidEventJson)?; + if !event.verify_id() { + return Err(RadrootsNostrBlossomError::InvalidEventId); + } + if !event.verify_signature() { + return Err(RadrootsNostrBlossomError::InvalidEventSignature); + } + + let tags: Vec<Vec<String>> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let claim = RadrootsBlossomParsedAuthorizationClaim::parse( + &event.content, + event.created_at.as_secs(), + &tags, + )? + .validate(validation)?; + + Ok(RadrootsNostrVerifiedBlossomAuthorization { event, claim }) +} + +fn validate_raw_event_json(json: &str) -> Result<(), RadrootsNostrBlossomError> { + const EVENT_FIELDS: [&str; 7] = [ + "id", + "pubkey", + "created_at", + "kind", + "tags", + "content", + "sig", + ]; + + let value: serde_json::Value = + serde_json::from_str(json).map_err(|_| RadrootsNostrBlossomError::InvalidEventJson)?; + let object = value + .as_object() + .ok_or(RadrootsNostrBlossomError::InvalidEventJson)?; + if object.len() != EVENT_FIELDS.len() + || !EVENT_FIELDS.iter().all(|field| object.contains_key(*field)) + { + return Err(RadrootsNostrBlossomError::InvalidEventJson); + } + + let actual_kind = object + .get("kind") + .and_then(serde_json::Value::as_u64) + .ok_or(RadrootsNostrBlossomError::InvalidEventJson)?; + if actual_kind != u64::from(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND) { + return Err(RadrootsNostrBlossomError::InvalidEventKind { + actual: actual_kind, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blossom_signing_failure_maps_to_typed_adapter_error() { + let result = finish_signed_event(Err::<RadrootsNostrEvent, ()>(())); + assert_eq!(result, Err(RadrootsNostrBlossomError::EventSigning)); + } +} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -5,6 +5,9 @@ extern crate alloc; #[cfg(feature = "client")] pub mod client; +#[cfg(feature = "blossom")] +pub mod blossom; + pub mod error; pub mod events; pub mod filter; @@ -46,6 +49,15 @@ pub mod identity_profile; pub mod prelude { pub use crate::events::radroots_nostr_build_event; + #[cfg(feature = "blossom")] + pub use crate::blossom::{ + RadrootsNostrBlossomAuthorizationHeader, RadrootsNostrBlossomError, + RadrootsNostrSignedBlossomAuthorization, RadrootsNostrVerifiedBlossomAuthorization, + radroots_nostr_decode_verify_blossom_authorization_header, + radroots_nostr_encode_blossom_authorization_header, + radroots_nostr_sign_blossom_authorization, + }; + #[cfg(feature = "client")] pub use crate::client::{ RadrootsNostrClient, RadrootsNostrClientOptions, radroots_nostr_fetch_event_by_id, diff --git a/crates/nostr/tests/blossom_conformance.rs b/crates/nostr/tests/blossom_conformance.rs @@ -0,0 +1,615 @@ +#![cfg(feature = "blossom")] + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use radroots_blossom::{ + RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, + RadrootsBlossomAuthorizationContent, RadrootsBlossomAuthorizationTarget, + RadrootsBlossomAuthorizationValidation, RadrootsBlossomParsedAuthorizationClaim, + RadrootsBlossomServerDomain, RadrootsBlossomServerScopeRequirement, RadrootsBlossomSha256, +}; +use radroots_nostr::blossom::{ + RadrootsNostrBlossomError, radroots_nostr_decode_verify_blossom_authorization_header, + radroots_nostr_encode_blossom_authorization_header, radroots_nostr_sign_blossom_authorization, +}; +use radroots_nostr::types::{ + RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, + RadrootsNostrTag, RadrootsNostrTimestamp, +}; +use serde::Deserialize; +use serde_json::Value; +use std::{borrow::Cow, fs, path::Path}; + +const PACKAGED_VECTORS: &str = include_str!("fixtures/bud11_nostr_adapter.v1.json"); +const WORKSPACE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +const SECRET_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; +const SERVER: &str = "cdn.example.com"; +const CONTENT: &str = "Upload Victoria farm photo"; +const CREATED_AT: u64 = 1_700_000_000; +const LIFETIME: u64 = RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS; + +#[derive(Debug, Deserialize)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn checked_in_blossom_bud11_nostr_vectors_execute_against_public_api() { + let vectors = conformance_vectors(); + let suite: Suite = serde_json::from_str(&vectors).expect("BUD-11 Nostr vectors must parse"); + assert_eq!(suite.suite, "blossom_bud11_nostr_adapter"); + assert_eq!(suite.contract_version, "0.1.0"); + assert!(!suite.vectors.is_empty()); + + for vector in &suite.vectors { + execute_vector(vector); + } +} + +fn conformance_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_VECTORS, + "packaged BUD-11 Nostr vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute_vector(vector: &Vector) { + assert_embedded_event_matches_header(vector); + let validation = vector_validation(vector); + match vector.kind.as_str() { + "blossom.bud11.nostr.decode_verify.valid" => { + let verified = radroots_nostr_decode_verify_blossom_authorization_header( + input_str(vector, "header"), + &validation, + ) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + verified.event_id().to_string(), + expected_str(vector, "event_id"), + "{}", + vector.id + ); + assert_eq!( + verified.author().to_string(), + expected_str(vector, "pubkey"), + "{}", + vector.id + ); + assert_eq!( + verified.claim().action().as_str(), + expected_str(vector, "action"), + "{}", + vector.id + ); + assert_eq!( + verified.claim().content().as_str(), + expected_str(vector, "content"), + "{}", + vector.id + ); + assert_eq!( + verified.claim().created_at(), + expected_u64(vector, "created_at"), + "{}", + vector.id + ); + assert_eq!( + verified.claim().expiration(), + expected_u64(vector, "expiration"), + "{}", + vector.id + ); + assert_eq!( + verified + .claim() + .hashes() + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>(), + expected_strings(vector, "hashes"), + "{}", + vector.id + ); + assert_eq!( + verified + .claim() + .server_domains() + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>(), + expected_strings(vector, "servers"), + "{}", + vector.id + ); + } + "blossom.bud11.nostr.decode_verify.invalid" => { + let error = radroots_nostr_decode_verify_blossom_authorization_header( + input_str(vector, "header"), + &validation, + ) + .expect_err("invalid BUD-11 Nostr vector must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + if let Some(actual_kind) = vector.expected.get("actual_kind") { + assert_eq!( + error, + RadrootsNostrBlossomError::InvalidEventKind { + actual: actual_kind.as_u64().expect("actual_kind must be u64"), + }, + "{}", + vector.id + ); + } + } + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } +} + +fn assert_embedded_event_matches_header(vector: &Vector) { + let Some(event_json) = vector.input.get("event_json").and_then(Value::as_str) else { + return; + }; + let payload = header_payload(input_str(vector, "header")); + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .unwrap_or_else(|error| panic!("{} header failed Base64url decode: {error}", vector.id)); + assert_eq!(decoded, event_json.as_bytes(), "{}", vector.id); +} + +fn header_payload(header: &str) -> &str { + let first_space = header + .as_bytes() + .iter() + .position(|byte| *byte == b' ') + .expect("authorization header requires SP"); + assert!( + header.as_bytes()[..first_space].eq_ignore_ascii_case(b"Nostr"), + "authorization header requires Nostr scheme" + ); + let payload_start = header.as_bytes()[first_space..] + .iter() + .position(|byte| *byte != b' ') + .map_or(header.len(), |offset| first_space + offset); + &header[payload_start..] +} + +fn vector_validation(vector: &Vector) -> RadrootsBlossomAuthorizationValidation { + let validation = vector.input.get("validation").expect("validation input"); + let target = validation.get("target").expect("validation target"); + let target_type = target + .get("type") + .and_then(Value::as_str) + .expect("validation target type"); + let target_hash = || { + RadrootsBlossomSha256::from_hex( + target + .get("hash") + .and_then(Value::as_str) + .expect("validation target hash"), + ) + .expect("valid validation target hash") + }; + let target = match target_type { + "get_blob" => RadrootsBlossomAuthorizationTarget::GetBlob(target_hash()), + "upload" => RadrootsBlossomAuthorizationTarget::Upload(target_hash()), + "list" => RadrootsBlossomAuthorizationTarget::List, + "delete_blob" => RadrootsBlossomAuthorizationTarget::DeleteBlob(target_hash()), + "mirror" => RadrootsBlossomAuthorizationTarget::Mirror(target_hash()), + "media" => RadrootsBlossomAuthorizationTarget::Media(target_hash()), + other => panic!("{} has unsupported target {other}", vector.id), + }; + let server = RadrootsBlossomServerDomain::parse( + validation + .get("server_domain") + .and_then(Value::as_str) + .expect("validation server_domain"), + ) + .expect("valid validation server_domain"); + let server_scope = match validation + .get("server_scope") + .and_then(Value::as_str) + .expect("validation server_scope") + { + "optional_any_match" => RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + "required_any_match" => RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + other => panic!("{} has unsupported server scope {other}", vector.id), + }; + RadrootsBlossomAuthorizationValidation::new( + target, + server, + server_scope, + validation + .get("now") + .and_then(Value::as_u64) + .expect("validation now"), + validation + .get("max_created_age") + .and_then(Value::as_u64) + .expect("validation max_created_age"), + ) + .expect("valid vector validation") +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector + .input + .get(field) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{} missing string input {field}", vector.id)) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector + .expected + .get(field) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{} missing string expectation {field}", vector.id)) +} + +fn expected_u64(vector: &Vector, field: &str) -> u64 { + vector + .expected + .get(field) + .and_then(Value::as_u64) + .unwrap_or_else(|| panic!("{} missing u64 expectation {field}", vector.id)) +} + +fn expected_strings(vector: &Vector, field: &str) -> Vec<String> { + vector + .expected + .get(field) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{} missing array expectation {field}", vector.id)) + .iter() + .map(|value| { + value + .as_str() + .unwrap_or_else(|| panic!("{} has non-string {field} value", vector.id)) + .to_owned() + }) + .collect() +} + +fn keys() -> RadrootsNostrKeys { + RadrootsNostrKeys::parse(SECRET_KEY).expect("fixed test secret key") +} + +fn hash() -> RadrootsBlossomSha256 { + RadrootsBlossomSha256::from_hex(HASH).expect("fixed test hash") +} + +fn server() -> RadrootsBlossomServerDomain { + RadrootsBlossomServerDomain::parse(SERVER).expect("fixed test server") +} + +fn authored_claim() -> RadrootsBlossomAuthoredUploadClaim { + RadrootsBlossomAuthoredUploadClaim::new( + RadrootsBlossomAuthorizationContent::parse(CONTENT).expect("fixed test content"), + server(), + hash(), + CREATED_AT, + LIFETIME, + ) + .expect("fixed authored claim") +} + +fn validation() -> RadrootsBlossomAuthorizationValidation { + RadrootsBlossomAuthorizationValidation::new( + RadrootsBlossomAuthorizationTarget::Upload(hash()), + server(), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + CREATED_AT + 1, + RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, + ) + .expect("fixed validation policy") +} + +fn raw_header(event: &RadrootsNostrEvent) -> String { + let json = serde_json::to_vec(event).expect("test event JSON"); + raw_json_header(&String::from_utf8(json).expect("test event JSON is UTF-8")) +} + +fn raw_json_header(json: &str) -> String { + format!("Nostr {}", URL_SAFE_NO_PAD.encode(json)) +} + +fn event_from_header(header: &str) -> RadrootsNostrEvent { + let json = URL_SAFE_NO_PAD + .decode(header_payload(header)) + .expect("test authorization payload"); + serde_json::from_slice(&json).expect("test authorization event") +} + +fn sign_raw(kind: u16, content: &str, tags: Vec<RadrootsNostrTag>) -> RadrootsNostrEvent { + RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(kind), content) + .tags(tags) + .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT)) + .sign_with_keys(&keys()) + .expect("raw test event signs") +} + +#[test] +fn blossom_authored_claim_signs_and_roundtrips_without_signature_assumptions() { + let claim = authored_claim(); + let signed = radroots_nostr_sign_blossom_authorization(&keys(), &claim) + .expect("sign authored authorization"); + let wire = claim.wire_parts(); + + let header = radroots_nostr_encode_blossom_authorization_header(&signed); + let event = event_from_header(header.as_str()); + + assert_eq!(event.kind.as_u16(), wire.kind()); + assert_eq!(event.created_at.as_secs(), wire.created_at()); + assert_eq!(event.content, wire.content()); + assert_eq!( + event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::<Vec<_>>(), + wire.tags() + ); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert_eq!(signed.event_id(), event.id); + assert_eq!(signed.author(), event.pubkey); + assert_eq!(signed.created_at(), event.created_at); + + assert!(header.as_str().starts_with("Nostr ")); + assert!(!header.as_str().contains('=')); + assert_eq!(header.as_ref(), header.as_str()); + assert!(!format!("{header:?}").contains(header.as_str())); + assert!(!format!("{signed:?}").contains(&event.sig.to_string())); + + let verified = + radroots_nostr_decode_verify_blossom_authorization_header(header.as_str(), &validation()) + .expect("verify header"); + assert_eq!(verified.event_id(), event.id); + assert_eq!(verified.author(), keys().public_key()); + assert_eq!(verified.created_at(), event.created_at); + assert_eq!(verified.claim().content().to_string(), CONTENT); + assert_eq!(verified.claim().created_at(), CREATED_AT); + assert_eq!(verified.claim().action().to_string(), "upload"); + assert_eq!(verified.claim().expiration(), CREATED_AT + LIFETIME); + assert_eq!(verified.claim().server_domains(), &[server()]); + assert_eq!(verified.claim().hashes(), &[hash()]); + assert!(!format!("{verified:?}").contains(&event.sig.to_string())); +} + +#[test] +fn blossom_header_rejects_noncanonical_and_malformed_encodings() { + let signed = radroots_nostr_sign_blossom_authorization(&keys(), &authored_claim()) + .expect("sign authored authorization"); + let valid = radroots_nostr_encode_blossom_authorization_header(&signed).into_string(); + let policy = validation(); + + for accepted in [ + valid.replacen("Nostr ", "nostr ", 1), + valid.replacen("Nostr ", "NOSTR ", 1), + ] { + radroots_nostr_decode_verify_blossom_authorization_header(&accepted, &policy) + .expect("auth-scheme is case-insensitive and accepts 1*SP"); + } + + let cases = [ + ( + format!(" {valid}"), + RadrootsNostrBlossomError::InvalidHeaderWhitespace, + ), + ( + "Nostr e 30".to_owned(), + RadrootsNostrBlossomError::InvalidHeaderWhitespace, + ), + ( + "Nostr".to_owned(), + RadrootsNostrBlossomError::InvalidHeaderScheme, + ), + ( + "Nostr ".to_owned(), + RadrootsNostrBlossomError::EmptyHeaderPayload, + ), + ( + format!("{valid}="), + RadrootsNostrBlossomError::HeaderPaddingForbidden, + ), + ( + "Nostr +".to_owned(), + RadrootsNostrBlossomError::InvalidHeaderBase64, + ), + ( + "Nostr Zh".to_owned(), + RadrootsNostrBlossomError::NonCanonicalHeaderBase64, + ), + ( + "Nostr _w".to_owned(), + RadrootsNostrBlossomError::InvalidHeaderUtf8, + ), + ( + "Nostr e30".to_owned(), + RadrootsNostrBlossomError::InvalidEventJson, + ), + ]; + + for (header, expected) in cases { + assert_eq!( + radroots_nostr_decode_verify_blossom_authorization_header(&header, &policy), + Err(expected), + "{header}" + ); + } +} + +#[test] +fn blossom_header_rejects_kind_narrowing_and_json_shape_laundering() { + let signed = radroots_nostr_sign_blossom_authorization(&keys(), &authored_claim()) + .expect("sign authored authorization"); + let encoded = radroots_nostr_encode_blossom_authorization_header(&signed); + let event_json = + serde_json::to_string(&event_from_header(encoded.as_str())).expect("event JSON"); + let policy = validation(); + + let cases = [ + ( + event_json.replacen("\"kind\":24242", "\"kind\":89778", 1), + RadrootsNostrBlossomError::InvalidEventKind { actual: 89_778 }, + ), + ( + format!( + "{},\"unknown\":true}}", + event_json.strip_suffix('}').unwrap() + ), + RadrootsNostrBlossomError::InvalidEventJson, + ), + ( + event_json.replacen("\"content\":", "\"unknown\":", 1), + RadrootsNostrBlossomError::InvalidEventJson, + ), + ( + event_json.replacen("\"kind\":24242", "\"kind\":24242,\"kind\":24242", 1), + RadrootsNostrBlossomError::InvalidEventJson, + ), + ( + event_json.replacen("\"kind\":24242", "\"kind\":\"24242\"", 1), + RadrootsNostrBlossomError::InvalidEventJson, + ), + ("[]".to_owned(), RadrootsNostrBlossomError::InvalidEventJson), + ("{".to_owned(), RadrootsNostrBlossomError::InvalidEventJson), + ]; + + for (json, expected) in cases { + assert_eq!( + radroots_nostr_decode_verify_blossom_authorization_header( + &raw_json_header(&json), + &policy, + ), + Err(expected), + "{json}" + ); + } +} + +#[test] +fn blossom_header_authenticates_before_claim_parsing() { + let policy = validation(); + + let wrong_kind = sign_raw(1, "", Vec::new()); + assert_eq!( + radroots_nostr_decode_verify_blossom_authorization_header( + &raw_header(&wrong_kind), + &policy + ), + Err(RadrootsNostrBlossomError::InvalidEventKind { actual: 1 }) + ); + + let malformed_claim = sign_raw(RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, "", Vec::new()); + let mut bad_id = malformed_claim.clone(); + bad_id.content.push('x'); + assert_eq!( + radroots_nostr_decode_verify_blossom_authorization_header(&raw_header(&bad_id), &policy), + Err(RadrootsNostrBlossomError::InvalidEventId) + ); + + let mut bad_signature = malformed_claim.clone(); + bad_signature.sig = sign_raw( + RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + "different", + Vec::new(), + ) + .sig; + assert!(bad_signature.verify_id()); + assert!(!bad_signature.verify_signature()); + assert_eq!( + radroots_nostr_decode_verify_blossom_authorization_header( + &raw_header(&bad_signature), + &policy + ), + Err(RadrootsNostrBlossomError::InvalidEventSignature) + ); + + let raw_tags: Vec<Vec<String>> = Vec::new(); + let pure_error = RadrootsBlossomParsedAuthorizationClaim::parse("", CREATED_AT, &raw_tags) + .expect_err("empty content is not a claim"); + let adapter_error = radroots_nostr_decode_verify_blossom_authorization_header( + &raw_header(&malformed_claim), + &policy, + ) + .expect_err("authenticated malformed claim must fail"); + assert_eq!(adapter_error.code(), pure_error.code()); + assert_eq!(adapter_error.blossom_claim_error(), Some(&pure_error)); + assert_eq!(adapter_error.to_string(), pure_error.to_string()); + + let signed = radroots_nostr_sign_blossom_authorization(&keys(), &authored_claim()) + .expect("sign authored authorization"); + let media_policy = RadrootsBlossomAuthorizationValidation::new( + RadrootsBlossomAuthorizationTarget::Media(hash()), + server(), + RadrootsBlossomServerScopeRequirement::RequiredAnyMatch, + CREATED_AT + 1, + RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, + ) + .expect("media validation policy"); + let validation_error = radroots_nostr_decode_verify_blossom_authorization_header( + radroots_nostr_encode_blossom_authorization_header(&signed).as_str(), + &media_policy, + ) + .expect_err("upload claim must not authorize media endpoint"); + assert_eq!(validation_error.code(), "authorization_action_mismatch"); +} + +#[test] +fn blossom_adapter_error_codes_are_stable_and_distinct() { + let errors = [ + RadrootsNostrBlossomError::InvalidHeaderWhitespace, + RadrootsNostrBlossomError::InvalidHeaderScheme, + RadrootsNostrBlossomError::EmptyHeaderPayload, + RadrootsNostrBlossomError::HeaderPaddingForbidden, + RadrootsNostrBlossomError::InvalidHeaderBase64, + RadrootsNostrBlossomError::NonCanonicalHeaderBase64, + RadrootsNostrBlossomError::InvalidHeaderUtf8, + RadrootsNostrBlossomError::InvalidEventJson, + RadrootsNostrBlossomError::InvalidEventKind { actual: 1 }, + RadrootsNostrBlossomError::InvalidEventId, + RadrootsNostrBlossomError::InvalidEventSignature, + RadrootsNostrBlossomError::EventSigning, + ]; + let mut codes = errors + .iter() + .map(RadrootsNostrBlossomError::code) + .collect::<Vec<_>>(); + let before = codes.len(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), before); + assert!(errors.iter().all(|error| !error.to_string().is_empty())); + assert_eq!(errors[0].blossom_claim_error(), None); +} diff --git a/crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json b/crates/nostr/tests/fixtures/bud11_nostr_adapter.v1.json @@ -0,0 +1,234 @@ +{ + "suite": "blossom_bud11_nostr_adapter", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "decode-verified-upload", + "kind": "blossom.bud11.nostr.decode_verify.valid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", + "server_scope": "required_any_match", + "now": 1700000001, + "max_created_age": 300 + } + }, + "expected": { + "event_id": "3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "action": "upload", + "content": "Upload Victoria farm photo", + "created_at": 1700000000, + "expiration": 1700000300, + "hashes": ["2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"], + "servers": ["cdn.example.com"] + } + }, + { + "id": "decode-leading-whitespace", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": " Nostr e30", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_whitespace" } + }, + { + "id": "decode-wrong-scheme", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Bearer e30", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_scheme" } + }, + { + "id": "decode-empty-payload", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr ", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "empty_header_payload" } + }, + { + "id": "decode-padding-forbidden", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr e30=", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "header_padding_forbidden" } + }, + { + "id": "decode-invalid-base64url-alphabet", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr +", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_base64" } + }, + { + "id": "decode-noncanonical-base64url-tail", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr Zh", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "noncanonical_header_base64" } + }, + { + "id": "decode-invalid-utf8", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr _w", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_header_utf8" } + }, + { + "id": "decode-invalid-event-json-shape", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr e30", + "event_json": "{}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-unknown-event-field", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSIsInVua25vd24iOnRydWV9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\",\"unknown\":true}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-duplicate-event-field", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsImtpbmQiOjI0MjQyLCJ0YWdzIjpbWyJ0IiwidXBsb2FkIl0sWyJleHBpcmF0aW9uIiwiMTcwMDAwMDMwMCJdLFsieCIsIjJjZjI0ZGJhNWZiMGEzMGUyNmU4M2IyYWM1YjllMjllMWIxNjFlNWMxZmE3NDI1ZTczMDQzMzYyOTM4Yjk4MjQiXSxbInNlcnZlciIsImNkbi5leGFtcGxlLmNvbSJdXSwiY29udGVudCI6IlVwbG9hZCBWaWN0b3JpYSBmYXJtIHBob3RvIiwic2lnIjoiODJjYTFhNjFlN2Q3YmQ5ZmJkODM1ZTZhNDY0YWM1NmJkZTliN2EzYjE4MDA4MjgzMjBjYTMwOGExMzVjMTQ0NGUxYjVmZDkxN2E1ZDIwN2VkYmU2ZDczN2NmYzQ3MGEwODRmMmRhZjNjMzgwN2YxNWIzY2VhNTZmNmI4OGM5NzkifQ", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_json" } + }, + { + "id": "decode-overflowed-event-kind", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6ODk3NzgsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI4MmNhMWE2MWU3ZDdiZDlmYmQ4MzVlNmE0NjRhYzU2YmRlOWI3YTNiMTgwMDgyODMyMGNhMzA4YTEzNWMxNDQ0ZTFiNWZkOTE3YTVkMjA3ZWRiZTZkNzM3Y2ZjNDcwYTA4NGYyZGFmM2MzODA3ZjE1YjNjZWE1NmY2Yjg4Yzk3OSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":89778,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_kind", "actual_kind": 89778 } + }, + { + "id": "decode-wrong-event-kind", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjE4NjhlOGFkNGNhNjZiN2E5YmI2ZGRhYWVjZGU2ZTVjYzVkMTE2ODJlODdhYmI2NTBhNmNlODg1Mzg1NGVmMDUiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MSwidGFncyI6W10sImNvbnRlbnQiOiIiLCJzaWciOiI0ODM1MTgzZDkzMmQ3M2ZjNmFhYTdjMTM3NDE3YzliMTgzZjVhZmFmNDk5NGRkMzU3MzlmMjllYjgyYjUxOTI2YmNhZjgyZGY3NjFjMDlhZDhjYTAzNDY3MGI1MmVmMjQzM2U1MzU1OTgzYWU0NGViNzVlOTI0NWYyZmU2ZmU3NiJ9", + "event_json": "{\"id\":\"1868e8ad4ca66b7a9bb6ddaaecde6e5cc5d11682e87abb650a6ce8853854ef05\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":1,\"tags\":[],\"content\":\"\",\"sig\":\"4835183d932d73fc6aaa7c137417c9b183f5afaf4994dd35739f29eb82b51926bcaf82df761c09ad8ca034670b52ef2433e5355983ae44eb75e9245f2fe6fe76\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_kind", "actual_kind": 1 } + }, + { + "id": "decode-invalid-event-id-before-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG94Iiwic2lnIjoiODJjYTFhNjFlN2Q3YmQ5ZmJkODM1ZTZhNDY0YWM1NmJkZTliN2EzYjE4MDA4MjgzMjBjYTMwOGExMzVjMTQ0NGUxYjVmZDkxN2E1ZDIwN2VkYmU2ZDczN2NmYzQ3MGEwODRmMmRhZjNjMzgwN2YxNWIzY2VhNTZmNmI4OGM5NzkifQ", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photox\",\"sig\":\"82ca1a61e7d7bd9fbd835e6a464ac56bde9b7a3b1800828320ca308a135c1444e1b5fd917a5d207edbe6d737cfc470a084f2daf3c3807f15b3cea56f6b88c979\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_id" } + }, + { + "id": "decode-invalid-event-signature-before-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6IjNjYzQ1MmUyN2ZkYjk1NDE4YjBkMzViM2E3M2I5OTAyNTgyM2ViM2NhOWVhZjQ1Y2RkMWE0ZDAyZGM4ZGNmMTEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltbInQiLCJ1cGxvYWQiXSxbImV4cGlyYXRpb24iLCIxNzAwMDAwMzAwIl0sWyJ4IiwiMmNmMjRkYmE1ZmIwYTMwZTI2ZTgzYjJhYzViOWUyOWUxYjE2MWU1YzFmYTc0MjVlNzMwNDMzNjI5MzhiOTgyNCJdLFsic2VydmVyIiwiY2RuLmV4YW1wbGUuY29tIl1dLCJjb250ZW50IjoiVXBsb2FkIFZpY3RvcmlhIGZhcm0gcGhvdG8iLCJzaWciOiI1YmIzMmVkZjMwY2MxYTJkNDlhNTUxYzc2YTExMzgxNDk4NzI1MjBlMDI4NjI4MjM5MjMzNTFjOGEyMzZkZTMwMjQxYTNlNDNkYjBlYTcyZWU4MTBmNGM4N2ZjYTQxMGU3ZTM5MjAyYmE2MDQ5NzM0ZWYzMTU3Y2VhYTRkOWEyZSJ9", + "event_json": "{\"id\":\"3cc452e27fdb95418b0d35b3a73b99025823eb3ca9eaf45cdd1a4d02dc8dcf11\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[[\"t\",\"upload\"],[\"expiration\",\"1700000300\"],[\"x\",\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"],[\"server\",\"cdn.example.com\"]],\"content\":\"Upload Victoria farm photo\",\"sig\":\"5bb32edf30cc1a2d49a551c76a1138149872520e02862823923351c8a236de30241a3e43db0ea72ee810f4c87fca410e7e39202ba6049734ef3157ceaa4d9a2e\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_event_signature" } + }, + { + "id": "decode-authenticated-invalid-claim", + "kind": "blossom.bud11.nostr.decode_verify.invalid", + "input": { + "header": "Nostr eyJpZCI6Ijc4YmVlMjI0NDIxNjA3MjNlMDRiZTgyYzhiZDAwNzVjNzkwNDY1YjRiOWYwYzFlMTllYzFiMWRhZWRlMGY0YjEiLCJwdWJrZXkiOiI3OWJlNjY3ZWY5ZGNiYmFjNTVhMDYyOTVjZTg3MGIwNzAyOWJmY2RiMmRjZTI4ZDk1OWYyODE1YjE2ZjgxNzk4IiwiY3JlYXRlZF9hdCI6MTcwMDAwMDAwMCwia2luZCI6MjQyNDIsInRhZ3MiOltdLCJjb250ZW50IjoiIiwic2lnIjoiZGQwY2UwNTNiOTNlZmY2N2IyNTZhODA0YWI4MjE0M2JhY2YyMzE0ZTNhZDNmYjU0MGZkNzE5ZGU0N2NjN2M4NGNiNDhlMTQwYWJhMzJhNTFiZmMwYmI1ODNlOThjMDllNDliMjU2MTkxZDJjYzY5OGU4YTA1ODJlOTZlODY2MzAifQ", + "event_json": "{\"id\":\"78bee22442160723e04be82c8bd0075c790465b4b9f0c1e19ec1b1daede0f4b1\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1700000000,\"kind\":24242,\"tags\":[],\"content\":\"\",\"sig\":\"dd0ce053b93eff67b256a804ab82143bacf2314e3ad3fb540fd719de47cc7c84cb48e140aba32a51bfc0bb583e98c09e49b256191d2cc698e8a0582e96e86630\"}", + "validation": { + "target": { "type": "upload", "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" }, + "server_domain": "cdn.example.com", "server_scope": "required_any_match", + "now": 1700000001, "max_created_age": 300 + } + }, + "expected": { "error": "invalid_authorization_content" } + } + ] +}