Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:09:51.499Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

contract.rs

1066 lines · 34.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.