Skip to repository content1066 lines · 34.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:09:51.499Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
contract.rs
1use std::{convert::Infallible, fmt};
2
3use app_identity::AppChannel;
4use nostr::{EventId, Kind, PublicKey, Tag, Timestamp, ToBech32, UnsignedEvent};
5use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6use sha2::{Digest, Sha256};
7use thiserror::Error;
8
9use crate::RecoveryProtectionStatus;
10
11pub const BUZZ_SOURCE_COMMIT: &str = "acfbb1bb6af54cb29cb152496ff43b8285dcb8cf";
12pub const IDENTITY_CONTRACT_VERSION: u32 = 1;
13pub const FRESH_PROFILE_ID: &str = "openagents.omega.nostr_only.v1";
14pub const PUBLIC_MANIFEST_SCHEMA: &str = "openagents.omega.identity-manifest.v1";
15pub const COMPLETION_RECORD_SCHEMA: &str = "openagents.omega.identity-completion.v1";
16pub const KEYRING_ACCOUNT: &str = "omega-sovereign-identity-v1";
17
18#[derive(Debug, Error)]
19pub enum ContractError {
20 #[error("{field} must be a non-empty portable identifier")]
21 InvalidReference { field: &'static str },
22 #[error("invalid Nostr public key")]
23 InvalidPublicKey,
24 #[error("public identity does not match its public key")]
25 PublicIdentityMismatch,
26 #[error("manifest uses an unsupported schema, version, or profile")]
27 UnsupportedManifest,
28 #[error("manifest keyring locator does not match the selected application channel")]
29 WrongKeyringLocator,
30 #[error("signing request contains an invalid tag")]
31 InvalidTag,
32 #[error("signing request is not admitted by the Omega identity contract")]
33 SigningNotAdmitted,
34 #[error("public document serialization failed")]
35 Serialization(#[from] serde_json::Error),
36}
37
38#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
39#[serde(transparent)]
40pub struct IdentityRef(String);
41
42impl IdentityRef {
43 pub fn new(value: impl Into<String>) -> Result<Self, ContractError> {
44 let value = value.into();
45 validate_reference("identity_ref", &value)?;
46 Ok(Self(value))
47 }
48
49 pub fn as_str(&self) -> &str {
50 &self.0
51 }
52}
53
54impl fmt::Debug for IdentityRef {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 formatter.debug_tuple("IdentityRef").field(&self.0).finish()
57 }
58}
59
60impl<'de> Deserialize<'de> for IdentityRef {
61 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62 where
63 D: Deserializer<'de>,
64 {
65 Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
66 }
67}
68
69#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
70#[serde(transparent)]
71pub struct ReceiptRef(String);
72
73impl ReceiptRef {
74 pub fn new(value: impl Into<String>) -> Result<Self, ContractError> {
75 let value = value.into();
76 validate_reference("receipt_ref", &value)?;
77 Ok(Self(value))
78 }
79
80 pub fn as_str(&self) -> &str {
81 &self.0
82 }
83}
84
85impl fmt::Debug for ReceiptRef {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter.debug_tuple("ReceiptRef").field(&self.0).finish()
88 }
89}
90
91impl<'de> Deserialize<'de> for ReceiptRef {
92 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93 where
94 D: Deserializer<'de>,
95 {
96 Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
97 }
98}
99
100fn validate_reference(field: &'static str, value: &str) -> Result<(), ContractError> {
101 if value.is_empty()
102 || value.len() > 128
103 || !value
104 .bytes()
105 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
106 {
107 return Err(ContractError::InvalidReference { field });
108 }
109 Ok(())
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(transparent)]
114pub struct NostrPublicKeyHex(String);
115
116impl NostrPublicKeyHex {
117 pub fn new(value: impl AsRef<str>) -> Result<Self, ContractError> {
118 let public_key = parse_public_key(value.as_ref())?;
119 Ok(Self(public_key.to_hex()))
120 }
121
122 pub fn as_str(&self) -> &str {
123 &self.0
124 }
125
126 pub(crate) fn public_key(&self) -> Result<PublicKey, ContractError> {
127 parse_public_key(&self.0)
128 }
129}
130
131impl<'de> Deserialize<'de> for NostrPublicKeyHex {
132 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
133 where
134 D: Deserializer<'de>,
135 {
136 Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
137 }
138}
139
140fn parse_public_key(value: &str) -> Result<PublicKey, ContractError> {
141 let public_key = PublicKey::from_hex(value).map_err(|_| ContractError::InvalidPublicKey)?;
142 public_key
143 .xonly()
144 .map_err(|_| ContractError::InvalidPublicKey)?;
145 Ok(public_key)
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
149#[serde(transparent)]
150pub struct Npub(String);
151
152impl Npub {
153 fn from_public_key(public_key: &PublicKey) -> Self {
154 let encoded = public_key
155 .to_bech32()
156 .unwrap_or_else(|never: Infallible| match never {});
157 Self(encoded)
158 }
159
160 pub fn as_str(&self) -> &str {
161 &self.0
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166#[serde(transparent)]
167pub struct PublicFingerprint(String);
168
169impl PublicFingerprint {
170 fn from_public_key(public_key: &PublicKey) -> Self {
171 Self(hex::encode(Sha256::digest(public_key.as_bytes())))
172 }
173
174 pub fn as_str(&self) -> &str {
175 &self.0
176 }
177
178 pub fn display(&self) -> String {
179 self.0
180 .chars()
181 .take(16)
182 .collect::<String>()
183 .as_bytes()
184 .chunks(4)
185 .map(|chunk| String::from_utf8_lossy(chunk).to_uppercase())
186 .collect::<Vec<_>>()
187 .join(" ")
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct PublicIdentity {
193 identity_ref: IdentityRef,
194 public_key_hex: NostrPublicKeyHex,
195 npub: Npub,
196 fingerprint: PublicFingerprint,
197}
198
199impl<'de> Deserialize<'de> for PublicIdentity {
200 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
201 where
202 D: Deserializer<'de>,
203 {
204 #[derive(Deserialize)]
205 #[serde(deny_unknown_fields)]
206 struct PublicIdentityWire {
207 identity_ref: IdentityRef,
208 public_key_hex: NostrPublicKeyHex,
209 npub: String,
210 fingerprint: String,
211 }
212
213 let wire = PublicIdentityWire::deserialize(deserializer)?;
214 let identity = Self {
215 identity_ref: wire.identity_ref,
216 public_key_hex: wire.public_key_hex,
217 npub: Npub(wire.npub),
218 fingerprint: PublicFingerprint(wire.fingerprint),
219 };
220 identity.validate().map_err(D::Error::custom)?;
221 Ok(identity)
222 }
223}
224
225impl PublicIdentity {
226 pub fn from_public_key_hex(
227 identity_ref: IdentityRef,
228 public_key_hex: impl AsRef<str>,
229 ) -> Result<Self, ContractError> {
230 let public_key = parse_public_key(public_key_hex.as_ref())?;
231 Ok(Self {
232 identity_ref,
233 public_key_hex: NostrPublicKeyHex(public_key.to_hex()),
234 npub: Npub::from_public_key(&public_key),
235 fingerprint: PublicFingerprint::from_public_key(&public_key),
236 })
237 }
238
239 pub fn validate(&self) -> Result<(), ContractError> {
240 validate_reference("identity_ref", self.identity_ref.as_str())?;
241 let public_key = self.public_key_hex.public_key()?;
242 if self.npub != Npub::from_public_key(&public_key)
243 || self.fingerprint != PublicFingerprint::from_public_key(&public_key)
244 {
245 return Err(ContractError::PublicIdentityMismatch);
246 }
247 Ok(())
248 }
249
250 pub fn identity_ref(&self) -> &IdentityRef {
251 &self.identity_ref
252 }
253
254 pub fn public_key_hex(&self) -> &NostrPublicKeyHex {
255 &self.public_key_hex
256 }
257
258 pub fn npub(&self) -> &Npub {
259 &self.npub
260 }
261
262 pub fn fingerprint(&self) -> &PublicFingerprint {
263 &self.fingerprint
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct KeyringLocator {
270 service: String,
271 account: String,
272}
273
274impl KeyringLocator {
275 pub fn for_channel(channel: AppChannel) -> Self {
276 Self {
277 service: channel.credential_namespace().to_string(),
278 account: KEYRING_ACCOUNT.to_string(),
279 }
280 }
281
282 pub fn service(&self) -> &str {
283 &self.service
284 }
285
286 pub fn account(&self) -> &str {
287 &self.account
288 }
289
290 pub(crate) fn proof(service: &'static str, account: &'static str) -> Self {
291 Self {
292 service: service.to_string(),
293 account: account.to_string(),
294 }
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct IdentityManifest {
301 schema: String,
302 contract_version: u32,
303 profile: String,
304 identity: PublicIdentity,
305 keyring_locator: KeyringLocator,
306 receipt_refs: Vec<ReceiptRef>,
307}
308
309impl IdentityManifest {
310 pub fn new(
311 identity: PublicIdentity,
312 keyring_locator: KeyringLocator,
313 receipt_refs: Vec<ReceiptRef>,
314 ) -> Self {
315 Self {
316 schema: PUBLIC_MANIFEST_SCHEMA.to_string(),
317 contract_version: IDENTITY_CONTRACT_VERSION,
318 profile: FRESH_PROFILE_ID.to_string(),
319 identity,
320 keyring_locator,
321 receipt_refs,
322 }
323 }
324
325 pub fn validate(&self, channel: AppChannel) -> Result<(), ContractError> {
326 self.validate_for_locator(&KeyringLocator::for_channel(channel))
327 }
328
329 pub(crate) fn validate_for_locator(
330 &self,
331 expected_locator: &KeyringLocator,
332 ) -> Result<(), ContractError> {
333 if self.schema != PUBLIC_MANIFEST_SCHEMA
334 || self.contract_version != IDENTITY_CONTRACT_VERSION
335 || self.profile != FRESH_PROFILE_ID
336 {
337 return Err(ContractError::UnsupportedManifest);
338 }
339 self.identity.validate()?;
340 for receipt_ref in &self.receipt_refs {
341 validate_reference("receipt_ref", receipt_ref.as_str())?;
342 }
343 if &self.keyring_locator != expected_locator {
344 return Err(ContractError::WrongKeyringLocator);
345 }
346 Ok(())
347 }
348
349 pub fn identity(&self) -> &PublicIdentity {
350 &self.identity
351 }
352
353 pub fn keyring_locator(&self) -> &KeyringLocator {
354 &self.keyring_locator
355 }
356
357 pub fn canonical_json(&self) -> Result<String, ContractError> {
358 Ok(serde_json::to_string(self)?)
359 }
360
361 pub fn digest(&self) -> Result<String, ContractError> {
362 Ok(hex::encode(Sha256::digest(
363 self.canonical_json()?.as_bytes(),
364 )))
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(deny_unknown_fields)]
370pub struct CompletionRecord {
371 schema: String,
372 contract_version: u32,
373 identity_ref: IdentityRef,
374 public_key_hex: NostrPublicKeyHex,
375 manifest_digest: String,
376 receipt_ref: ReceiptRef,
377}
378
379impl CompletionRecord {
380 pub fn new(
381 manifest: &IdentityManifest,
382 receipt_ref: ReceiptRef,
383 channel: AppChannel,
384 ) -> Result<Self, ContractError> {
385 let record = Self {
386 schema: COMPLETION_RECORD_SCHEMA.to_string(),
387 contract_version: IDENTITY_CONTRACT_VERSION,
388 identity_ref: manifest.identity.identity_ref.clone(),
389 public_key_hex: manifest.identity.public_key_hex.clone(),
390 manifest_digest: manifest.digest()?,
391 receipt_ref,
392 };
393 record.validate_against(manifest, channel)?;
394 Ok(record)
395 }
396
397 pub(crate) fn new_for_locator(
398 manifest: &IdentityManifest,
399 receipt_ref: ReceiptRef,
400 locator: &KeyringLocator,
401 ) -> Result<Self, ContractError> {
402 let record = Self {
403 schema: COMPLETION_RECORD_SCHEMA.to_string(),
404 contract_version: IDENTITY_CONTRACT_VERSION,
405 identity_ref: manifest.identity.identity_ref.clone(),
406 public_key_hex: manifest.identity.public_key_hex.clone(),
407 manifest_digest: manifest.digest()?,
408 receipt_ref,
409 };
410 record.validate_against_locator(manifest, locator)?;
411 Ok(record)
412 }
413
414 pub fn validate(&self) -> Result<(), ContractError> {
415 if self.schema != COMPLETION_RECORD_SCHEMA
416 || self.contract_version != IDENTITY_CONTRACT_VERSION
417 {
418 return Err(ContractError::UnsupportedManifest);
419 }
420 validate_reference("identity_ref", self.identity_ref.as_str())?;
421 self.public_key_hex.public_key()?;
422 validate_reference("receipt_ref", self.receipt_ref.as_str())?;
423 if self.manifest_digest.len() != 64
424 || !self
425 .manifest_digest
426 .bytes()
427 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
428 {
429 return Err(ContractError::UnsupportedManifest);
430 }
431 Ok(())
432 }
433
434 pub fn receipt_ref(&self) -> &ReceiptRef {
435 &self.receipt_ref
436 }
437
438 pub fn validate_against(
439 &self,
440 manifest: &IdentityManifest,
441 channel: AppChannel,
442 ) -> Result<(), ContractError> {
443 manifest.validate(channel)?;
444 self.validate_against_valid_manifest(manifest)
445 }
446
447 pub(crate) fn validate_against_locator(
448 &self,
449 manifest: &IdentityManifest,
450 locator: &KeyringLocator,
451 ) -> Result<(), ContractError> {
452 manifest.validate_for_locator(locator)?;
453 self.validate_against_valid_manifest(manifest)
454 }
455
456 fn validate_against_valid_manifest(
457 &self,
458 manifest: &IdentityManifest,
459 ) -> Result<(), ContractError> {
460 self.validate()?;
461 if self.identity_ref != manifest.identity.identity_ref
462 || self.public_key_hex != manifest.identity.public_key_hex
463 || self.manifest_digest != manifest.digest()?
464 || !manifest.receipt_refs.contains(&self.receipt_ref)
465 {
466 return Err(ContractError::UnsupportedManifest);
467 }
468 Ok(())
469 }
470}
471
472#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "kebab-case")]
474pub enum CustodyState {
475 Absent,
476 Ready,
477 Locked,
478 Incomplete,
479 Lost,
480 Conflict,
481 ResetFailed,
482 RelaunchRequired,
483}
484
485#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(rename_all = "kebab-case")]
487pub enum RecoveryState {
488 NotRequired,
489 DiscoveryRequired,
490 CandidateFound,
491 OwnerSelectionRequired,
492 Blocked,
493 Complete,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(tag = "operation", rename_all = "snake_case")]
498#[serde(deny_unknown_fields)]
499pub enum CustodyRequest {
500 Inspect,
501 Create {
502 request_ref: ReceiptRef,
503 },
504 Import {
505 request_ref: ReceiptRef,
506 },
507 Open {
508 expected_identity: IdentityRef,
509 },
510 Reset {
511 expected_identity: IdentityRef,
512 authorization_ref: ReceiptRef,
513 },
514}
515
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct CustodyResult {
519 pub state: CustodyState,
520 pub identity: Option<PublicIdentity>,
521 pub receipt_ref: Option<ReceiptRef>,
522}
523
524#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(rename_all = "kebab-case")]
526pub enum PendingIdentityOperation {
527 Create,
528 Import,
529 ResolveConflict,
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(deny_unknown_fields)]
534pub struct PendingIdentityTransaction {
535 pub operation: PendingIdentityOperation,
536 pub receipt_ref: ReceiptRef,
537 pub expected_identity: Option<PublicIdentity>,
538}
539
540#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "kebab-case")]
542pub enum CustodyConflictReason {
543 AmbiguousSecureStore,
544 PublicManifestCustodyMismatch,
545 PendingTransactionMismatch,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(deny_unknown_fields)]
550pub struct CustodyConflict {
551 pub reason: CustodyConflictReason,
552 pub identities: Vec<PublicIdentity>,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct IdentityInspection {
558 pub custody: CustodyResult,
559 pub pending_transaction: Option<PendingIdentityTransaction>,
560 pub conflict: Option<CustodyConflict>,
561 pub recovery_protection: RecoveryProtectionStatus,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565#[serde(deny_unknown_fields)]
566pub struct RecoveryRequest {
567 pub identity_ref: IdentityRef,
568 pub candidate_refs: Vec<IdentityRef>,
569 pub authorization_ref: Option<ReceiptRef>,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(deny_unknown_fields)]
574pub struct RecoveryResult {
575 pub state: RecoveryState,
576 pub identity: Option<PublicIdentity>,
577 pub receipt_ref: Option<ReceiptRef>,
578}
579
580#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "snake_case")]
582pub enum SigningPurpose {
583 NostrEvent,
584 Nip44EncryptedSelfEvent,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct UnsignedEventTemplate {
590 pub created_at: u64,
591 pub kind: u16,
592 pub tags: Vec<Vec<String>>,
593 pub content: String,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct AdmittedSigningRequest {
599 pub request_ref: ReceiptRef,
600 pub identity_ref: IdentityRef,
601 pub purpose: SigningPurpose,
602 pub event: UnsignedEventTemplate,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(deny_unknown_fields)]
607pub struct OwnerAttestationRequest {
608 pub request_ref: ReceiptRef,
609 pub identity_ref: IdentityRef,
610 pub agent_public_key_hex: NostrPublicKeyHex,
611 pub conditions: String,
612}
613
614impl OwnerAttestationRequest {
615 pub fn validate(&self, identity: &PublicIdentity) -> Result<(), ContractError> {
616 validate_reference("request_ref", self.request_ref.as_str())?;
617 validate_reference("identity_ref", self.identity_ref.as_str())?;
618 self.agent_public_key_hex.public_key()?;
619 if self.identity_ref != *identity.identity_ref()
620 || self.agent_public_key_hex == *identity.public_key_hex()
621 || self.conditions.len() > 1_024
622 || self.conditions.chars().any(char::is_control)
623 {
624 return Err(ContractError::SigningNotAdmitted);
625 }
626 Ok(())
627 }
628}
629
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(deny_unknown_fields)]
632pub struct OwnerAttestationResult {
633 pub request_ref: ReceiptRef,
634 pub identity: PublicIdentity,
635 pub agent_public_key_hex: NostrPublicKeyHex,
636 pub auth_tag: Vec<String>,
637}
638
639impl AdmittedSigningRequest {
640 pub fn validate(&self) -> Result<(), ContractError> {
641 validate_reference("request_ref", self.request_ref.as_str())?;
642 validate_reference("identity_ref", self.identity_ref.as_str())?;
643 if self.event.content.len() > 1_048_576 || self.event.tags.len() > 1_024 {
644 return Err(ContractError::SigningNotAdmitted);
645 }
646 self.parsed_tags()?;
647 Ok(())
648 }
649
650 pub fn event_id(&self, identity: &PublicIdentity) -> Result<EventId, ContractError> {
651 let mut unsigned_event = self.unsigned_event(identity)?;
652 Ok(unsigned_event.id())
653 }
654
655 pub(crate) fn unsigned_event(
656 &self,
657 identity: &PublicIdentity,
658 ) -> Result<UnsignedEvent, ContractError> {
659 self.validate()?;
660 identity.validate()?;
661 if &self.identity_ref != identity.identity_ref() {
662 return Err(ContractError::SigningNotAdmitted);
663 }
664 let public_key = identity.public_key_hex.public_key()?;
665 Ok(UnsignedEvent::new(
666 public_key,
667 Timestamp::from_secs(self.event.created_at),
668 Kind::from_u16(self.event.kind),
669 self.parsed_tags()?,
670 self.event.content.clone(),
671 ))
672 }
673
674 fn parsed_tags(&self) -> Result<Vec<Tag>, ContractError> {
675 self.event
676 .tags
677 .iter()
678 .cloned()
679 .map(|tag| Tag::parse(tag).map_err(|_| ContractError::InvalidTag))
680 .collect()
681 }
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct SigningResult {
687 pub request_ref: ReceiptRef,
688 pub identity: PublicIdentity,
689 pub event_id: String,
690 pub signature: String,
691 pub signed_event_json: String,
692}
693
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695#[serde(deny_unknown_fields)]
696pub struct PrivateMessageRequest {
697 pub request_ref: ReceiptRef,
698 pub identity_ref: IdentityRef,
699 pub recipients: Vec<NostrPublicKeyHex>,
700 pub rumor: UnsignedEventTemplate,
701}
702
703impl PrivateMessageRequest {
704 pub fn validate(&self) -> Result<(), ContractError> {
705 validate_reference("request_ref", self.request_ref.as_str())?;
706 validate_reference("identity_ref", self.identity_ref.as_str())?;
707 if self.recipients.is_empty()
708 || self.recipients.len() > 8
709 || self.rumor.kind != Kind::PrivateDirectMessage.as_u16()
710 || self.rumor.content.len() > 64 * 1024
711 || self.rumor.tags.len() > 128
712 {
713 return Err(ContractError::SigningNotAdmitted);
714 }
715 let mut unique = std::collections::HashSet::new();
716 for recipient in &self.recipients {
717 recipient.public_key()?;
718 if !unique.insert(recipient.as_str()) {
719 return Err(ContractError::SigningNotAdmitted);
720 }
721 }
722 self.rumor
723 .tags
724 .iter()
725 .cloned()
726 .map(|tag| Tag::parse(tag).map_err(|_| ContractError::InvalidTag))
727 .collect::<Result<Vec<_>, _>>()?;
728 let recipient_set: std::collections::HashSet<&str> = self
729 .recipients
730 .iter()
731 .map(NostrPublicKeyHex::as_str)
732 .collect();
733 let mut tagged_recipient_set = std::collections::HashSet::new();
734 for tag in &self.rumor.tags {
735 if tag.first().map(String::as_str) != Some("p") {
736 continue;
737 }
738 let tagged_recipient = tag.get(1).ok_or(ContractError::SigningNotAdmitted)?;
739 NostrPublicKeyHex::new(tagged_recipient)?;
740 if !tagged_recipient_set.insert(tagged_recipient.as_str()) {
741 return Err(ContractError::SigningNotAdmitted);
742 }
743 }
744 if tagged_recipient_set != recipient_set {
745 return Err(ContractError::SigningNotAdmitted);
746 }
747 Ok(())
748 }
749
750 pub(crate) fn unsigned_rumor(
751 &self,
752 identity: &PublicIdentity,
753 ) -> Result<UnsignedEvent, ContractError> {
754 self.validate()?;
755 identity.validate()?;
756 if &self.identity_ref != identity.identity_ref() {
757 return Err(ContractError::SigningNotAdmitted);
758 }
759 let tags = self
760 .rumor
761 .tags
762 .iter()
763 .cloned()
764 .map(|tag| Tag::parse(tag).map_err(|_| ContractError::InvalidTag))
765 .collect::<Result<Vec<_>, _>>()?;
766 let mut rumor = UnsignedEvent::new(
767 identity.public_key_hex().public_key()?,
768 Timestamp::from_secs(self.rumor.created_at),
769 Kind::PrivateDirectMessage,
770 tags,
771 self.rumor.content.clone(),
772 );
773 rumor.ensure_id();
774 Ok(rumor)
775 }
776}
777
778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(deny_unknown_fields)]
780pub struct GiftWrappedPrivateMessage {
781 pub receiver_public_key_hex: String,
782 pub rumor_event_id: String,
783 pub gift_wrap_event_json: String,
784}
785
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(deny_unknown_fields)]
788pub struct UnwrappedPrivateMessage {
789 pub rumor_event_id: String,
790 pub sender_public_key_hex: String,
791 pub created_at: u64,
792 pub tags: Vec<Vec<String>>,
793 pub content: String,
794}
795
796#[cfg(test)]
797mod tests {
798 use std::{collections::HashSet, str::FromStr};
799
800 use nostr::{EventId, JsonUtil, PublicKey};
801 use serde_json::Value;
802
803 use super::*;
804
805 const PUBLIC_KEY_HEX: &str = "f86c44a2de95d9149b51c6a29afeabba264c18e2fa7c49de93424a0c56947785";
806 const EXPECTED_NPUB: &str = "npub1lpkyfgk7jhv3fx63c63f4l4thgnycx8zlf7ynh5ngf9qc455w7zs7s8hua";
807 const EXPECTED_EVENT_ID: &str =
808 "2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45";
809
810 #[derive(Deserialize)]
811 #[serde(deny_unknown_fields)]
812 struct ContractVector {
813 source_commit: String,
814 profile: String,
815 public_identity: PublicIdentity,
816 fingerprint_display: String,
817 manifest: IdentityManifest,
818 manifest_digest: String,
819 signing_request: AdmittedSigningRequest,
820 expected_event_id: String,
821 signature: String,
822 }
823
824 fn identity() -> PublicIdentity {
825 PublicIdentity::from_public_key_hex(
826 IdentityRef::new("omega-test-identity").expect("valid fixture reference"),
827 PUBLIC_KEY_HEX,
828 )
829 .expect("valid fixture public key")
830 }
831
832 fn request() -> AdmittedSigningRequest {
833 AdmittedSigningRequest {
834 request_ref: ReceiptRef::new("signing-vector-1").expect("valid fixture reference"),
835 identity_ref: identity().identity_ref().clone(),
836 purpose: SigningPurpose::NostrEvent,
837 event: UnsignedEventTemplate {
838 created_at: 1_640_839_235,
839 kind: 4,
840 tags: vec![vec![
841 "p".to_string(),
842 "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d".to_string(),
843 ]],
844 content: "uRuvYr585B80L6rSJiHocw==?iv=oh6LVqdsYYol3JfFnXTbPA==".to_string(),
845 },
846 }
847 }
848
849 #[test]
850 fn keyring_locators_are_exact_and_unique() {
851 let locators = AppChannel::ALL.map(KeyringLocator::for_channel);
852 assert_eq!(
853 locators.each_ref().map(|locator| locator.service.as_str()),
854 [
855 "com.openagents.omega.credentials.dev",
856 "com.openagents.omega.credentials.nightly",
857 "com.openagents.omega.credentials.rc",
858 "com.openagents.omega.credentials",
859 ]
860 );
861 assert_eq!(
862 locators
863 .iter()
864 .map(KeyringLocator::service)
865 .collect::<HashSet<_>>()
866 .len(),
867 4
868 );
869 assert!(
870 locators
871 .iter()
872 .all(|locator| locator.account == KEYRING_ACCOUNT)
873 );
874 }
875
876 #[test]
877 fn public_key_vector_derives_npub_and_fingerprint() {
878 let identity = identity();
879 assert_eq!(identity.public_key_hex().as_str(), PUBLIC_KEY_HEX);
880 assert_eq!(identity.npub().as_str(), EXPECTED_NPUB);
881 assert_eq!(
882 identity.fingerprint().as_str(),
883 "daee948a8bf214f3d48ab71cf2b672bee2396252900e9d74296e1e3d7ad1d139"
884 );
885 assert_eq!(identity.fingerprint().display(), "DAEE 948A 8BF2 14F3");
886 }
887
888 #[test]
889 fn admitted_signing_vector_has_deterministic_event_id() {
890 let event_id = request()
891 .event_id(&identity())
892 .expect("fixture signing request is admitted");
893 assert_eq!(
894 event_id,
895 EventId::from_hex(EXPECTED_EVENT_ID).expect("valid fixture event id")
896 );
897 }
898
899 #[test]
900 fn checked_in_contract_vector_is_frozen_and_public_only() {
901 let vector_json = include_str!("../fixtures/omega_nostr_identity_v1.json");
902 let vector: ContractVector =
903 serde_json::from_str(vector_json).expect("parse checked-in contract vector");
904
905 assert_eq!(vector.source_commit, BUZZ_SOURCE_COMMIT);
906 assert_eq!(vector.profile, FRESH_PROFILE_ID);
907 vector.public_identity.validate().expect("public identity");
908 assert_eq!(
909 vector.public_identity.fingerprint().display(),
910 vector.fingerprint_display
911 );
912 vector
913 .manifest
914 .validate(AppChannel::Dev)
915 .expect("manifest contract");
916 assert_eq!(
917 vector.manifest.digest().expect("manifest digest"),
918 vector.manifest_digest
919 );
920 assert_eq!(
921 vector
922 .signing_request
923 .event_id(&vector.public_identity)
924 .expect("admitted signing request")
925 .to_hex(),
926 vector.expected_event_id
927 );
928 assert_eq!(vector.signature.len(), 128);
929 assert_public_only(
930 &serde_json::from_str(vector_json).expect("parse raw public fixture value"),
931 );
932 assert_public_only(
933 &serde_json::to_value(vector.manifest).expect("serialize public fixture"),
934 );
935 }
936
937 #[test]
938 fn admitted_signing_vector_signature_verifies() {
939 let event_json = format!(
940 r#"{{"id":"{EXPECTED_EVENT_ID}","pubkey":"{PUBLIC_KEY_HEX}","created_at":1640839235,"kind":4,"tags":[["p","13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d"]],"content":"uRuvYr585B80L6rSJiHocw==?iv=oh6LVqdsYYol3JfFnXTbPA==","sig":"a5d9290ef9659083c490b303eb7ee41356d8778ff19f2f91776c8dc4443388a64ffcf336e61af4c25c05ac3ae952d1ced889ed655b67790891222aaa15b99fdd"}}"#
941 );
942 let event = nostr::Event::from_json(event_json).expect("valid signed fixture");
943 event.verify().expect("fixture signature verifies");
944 assert_eq!(
945 event.pubkey,
946 PublicKey::from_str(PUBLIC_KEY_HEX).expect("valid fixture public key")
947 );
948 }
949
950 #[test]
951 fn malformed_or_wrong_identity_signing_requests_are_rejected() {
952 let mut malformed = request();
953 malformed.event.tags = vec![Vec::new()];
954 assert!(matches!(
955 malformed.validate(),
956 Err(ContractError::InvalidTag)
957 ));
958
959 let mut wrong_identity = request();
960 wrong_identity.identity_ref =
961 IdentityRef::new("another-identity").expect("valid fixture reference");
962 assert!(matches!(
963 wrong_identity.event_id(&identity()),
964 Err(ContractError::SigningNotAdmitted)
965 ));
966 }
967
968 #[test]
969 fn public_contract_serialization_contains_no_secret_or_wallet_fields() {
970 let manifest = IdentityManifest::new(
971 identity(),
972 KeyringLocator::for_channel(AppChannel::Dev),
973 vec![ReceiptRef::new("creation-receipt").expect("valid fixture reference")],
974 );
975 let value = serde_json::to_value(manifest).expect("serialize public fixture");
976 assert_public_only(&value);
977 }
978
979 #[test]
980 fn deserialization_enforces_reference_and_public_identity_invariants() {
981 assert!(serde_json::from_str::<IdentityRef>(r#""""#).is_err());
982 assert!(serde_json::from_str::<ReceiptRef>(r#""contains spaces""#).is_err());
983 assert!(serde_json::from_str::<NostrPublicKeyHex>(r#""not-a-public-key""#).is_err());
984
985 let mut value = serde_json::to_value(identity()).expect("serialize public identity");
986 let Some(object) = value.as_object_mut() else {
987 panic!("public identity fixture must serialize as an object");
988 };
989 object.insert("npub".to_string(), Value::String("npub1wrong".to_string()));
990 assert!(serde_json::from_value::<PublicIdentity>(value).is_err());
991 }
992
993 #[test]
994 fn manifest_rejects_unknown_secret_and_wallet_fields() {
995 let manifest = IdentityManifest::new(
996 identity(),
997 KeyringLocator::for_channel(AppChannel::Dev),
998 vec![ReceiptRef::new("creation-receipt").expect("valid fixture reference")],
999 );
1000 for forbidden_field in ["nsec", "secret_key", "wallet", "spark"] {
1001 let mut value = serde_json::to_value(&manifest).expect("serialize manifest");
1002 let Some(object) = value.as_object_mut() else {
1003 panic!("manifest fixture must serialize as an object");
1004 };
1005 object.insert(
1006 forbidden_field.to_string(),
1007 Value::String("forbidden".to_string()),
1008 );
1009 assert!(
1010 serde_json::from_value::<IdentityManifest>(value).is_err(),
1011 "accepted forbidden field {forbidden_field}"
1012 );
1013 }
1014 }
1015
1016 #[test]
1017 fn completion_record_must_match_its_manifest() {
1018 let receipt_ref = ReceiptRef::new("creation-receipt").expect("valid fixture reference");
1019 let manifest = IdentityManifest::new(
1020 identity(),
1021 KeyringLocator::for_channel(AppChannel::Dev),
1022 vec![receipt_ref.clone()],
1023 );
1024 let mut completion = CompletionRecord::new(&manifest, receipt_ref, AppChannel::Dev)
1025 .expect("completion record");
1026 completion.manifest_digest = "0".repeat(64);
1027 assert!(
1028 completion
1029 .validate_against(&manifest, AppChannel::Dev)
1030 .is_err()
1031 );
1032 }
1033
1034 fn assert_public_only(value: &Value) {
1035 const FORBIDDEN: [&str; 7] = [
1036 "spark",
1037 "wallet",
1038 "mnemonic",
1039 "seed",
1040 "nsec",
1041 "private_key",
1042 "secret_key",
1043 ];
1044 match value {
1045 Value::Object(object) => {
1046 for (key, value) in object {
1047 assert!(
1048 FORBIDDEN.iter().all(|forbidden| !key.contains(forbidden)),
1049 "forbidden public-contract field: {key}"
1050 );
1051 assert_public_only(value);
1052 }
1053 }
1054 Value::Array(values) => {
1055 for value in values {
1056 assert_public_only(value);
1057 }
1058 }
1059 Value::String(value) => {
1060 assert!(FORBIDDEN.iter().all(|forbidden| !value.contains(forbidden)));
1061 }
1062 _ => {}
1063 }
1064 }
1065}
1066