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:
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" }
+ }
+ ]
+}