Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/published-open.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ and [NAP-LINK](https://github.com/napplet/naps/blob/100e5c939ae6b1ea82cc58587c6d
event and checks the caller's expected ID before fetching the HTML. The loader
refuses same-time conflicts and checks the original bytes against the signed
manifest.
3. The shell presents the actual caller, target publisher and `d`, signed event ID,
verified artifact version, and required and optional capabilities, including
unavailable ones. The same review obtains first-open or changed access consent
when needed. Hypergolic saves approved access under the usual identity, publisher
and app scope. A later launch failure may leave that approved access in place.
3. The shell presents the requesting catalog, the target’s signed name and
description, its signing publisher, and the required and optional capabilities.
Unavailable capabilities show which features may be missing. Public profile data
comes from signed metadata by that publisher. The profile and the signed artwork
load separately from the decision. Technical details expose the full publisher
key, publication reference, signed event ID and verified hashes. Settings uses
the same layout for first-open access review. Hypergolic saves approved access
under the usual identity, publisher and app scope. A later launch failure may
leave that approved access in place.
4. Approval permits the exact reviewed artifact. The host reuses a matching mounted
view or stages the verified artifact through the ordinary one-use native transfer.
A different saved revision of the same publication coordinate yields a conflict
Expand Down
3 changes: 2 additions & 1 deletion src/napplets/discovery-open-broker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { verifiedLaunchMetadata } from './verified-launch-metadata.ts';
import { ReviewBusyError } from '../shell/review-slot.ts';
import { naddrEncode } from 'nostr-tools/nip19';
import { resolveNappletLink, type NappletCoordinate } from './resolve-link.ts';
Expand Down Expand Up @@ -173,7 +174,7 @@ async function openExact(ports: Ports, launch: Launch, attempt: Attempt, active:
const requiredDomains = Object.freeze([...manifest.requiredDomains].sort());
const optionalDomains = Object.freeze([...manifest.optionalDomains].sort());
const { unsupportedDomains, unsupportedOptionalDomains } = publishedCapabilityCompatibility(requiredDomains, optionalDomains);
const review = reviewLaunch(ports, launch, Object.freeze({ publisher, appId, eventId, version,
const review = reviewLaunch(ports, launch, Object.freeze({ publisher, appId, eventId, version, metadata: verifiedLaunchMetadata(artifact),
requiredDomains, optionalDomains, unsupportedDomains, unsupportedOptionalDomains }), attempt, active);
let next: NappletDescriptor;
if (loaded) {
Expand Down
4 changes: 3 additions & 1 deletion src/napplets/first-open-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AppStorageControl } from '../storage/database.ts';
import type { TrustedApp } from '../storage/ports.ts';
import { reviewedCapabilityCompatibility, type ReviewedSupportProfile } from '../runtime/support-report.ts';
import {verifiedPublicationIdentity} from './verified-publication-identity.ts';
import { verifiedLaunchMetadata, type VerifiedLaunchMetadata } from './verified-launch-metadata.ts';
import type {SnapshotIdentity} from './published-identity.ts';

export class FirstOpenConsentError extends Error {
Expand All @@ -16,6 +17,7 @@ export type NappletConsentReview = Readonly<{
publisher: string;
appId: string;
eventId: string;
metadata: VerifiedLaunchMetadata;
snapshot?: SnapshotIdentity;
domains: readonly string[];
unsupportedDomains: readonly string[];
Expand Down Expand Up @@ -118,7 +120,7 @@ export async function requestFirstOpenConsent(
}

if (!accepted) {
const request = Object.freeze({ user, publisher, appId, eventId, ...(publication.kind===5129?{snapshot:publication.snapshot}:{}),
const request = Object.freeze({ user, publisher, appId, eventId, metadata: verifiedLaunchMetadata(artifact), ...(publication.kind===5129?{snapshot:publication.snapshot}:{}),
domains: requestedDomains, unsupportedDomains,
optionalDomains, unsupportedOptionalDomains });
let approved = false;
Expand Down
29 changes: 29 additions & 0 deletions src/napplets/launch-review-media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { base64 } from '@scure/base';
import type { TrustedIdentityDataPort } from '../identity/identity-service.ts';
import type { ProfileAvatarReader } from '../identity/profile-avatar-image.ts';
import { classifyResourceBytes } from '../resources/resource-mime.ts';
import type { PublishedArtifactSource } from './loader.ts';
import type { PublishedLaunchIcon } from './verified-launch-metadata.ts';

export type LaunchReviewMedia = Readonly<{
readProfile: TrustedIdentityDataPort['readProfile'];
readAvatar: ProfileAvatarReader;
readIcon(icon: PublishedLaunchIcon, signal: AbortSignal): Promise<string>;
}>;

const MAX_ICON_BYTES = 768 * 1024;

export function createLaunchReviewMedia(profiles: Pick<TrustedIdentityDataPort, 'readProfile'>, readAvatar: ProfileAvatarReader,
blobs: Pick<PublishedArtifactSource, 'readHtml'>, servers: () => readonly string[]): LaunchReviewMedia {
return Object.freeze({
readProfile: profiles.readProfile,
readAvatar,
async readIcon(icon: PublishedLaunchIcon, signal: AbortSignal) {
const mirrors = [...new Set([...icon.serverHints, ...servers()])];
// The published transport bounds the read and verifies the signed Blossom hash.
const bytes = await blobs.readHtml(icon.hash, mirrors, MAX_ICON_BYTES, signal);
if (signal.aborted || classifyResourceBytes(bytes) !== icon.mime) throw new Error('Icon unavailable');
return `data:${icon.mime};base64,${base64.encode(bytes)}`;
},
});
}
34 changes: 34 additions & 0 deletions src/napplets/verified-launch-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { naddrEncode, neventEncode } from 'nostr-tools/nip19';
import { assertVerifiedArtifact, type VerifiedNappletArtifact } from './verified-artifact.ts';
import { verifiedPublicationIdentity } from './verified-publication-identity.ts';

const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
const DISPLAY_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u206f]/gu;

export type PublishedLaunchIcon = Readonly<{ hash: string; mime: string; serverHints: readonly string[] }>;
export type VerifiedLaunchMetadata = Readonly<{
title: string;
description: string;
icon?: PublishedLaunchIcon;
reference: string;
version: string;
htmlHash: string;
}>;

export function verifiedLaunchMetadata(artifact: VerifiedNappletArtifact): VerifiedLaunchMetadata {
assertVerifiedArtifact(artifact);
const { manifest, htmlHash, aggregateHash: version } = artifact;
const { title, appId, kind } = verifiedPublicationIdentity(manifest);
const reference = kind === 5129 ? neventEncode({ id: manifest.eventId, kind, author: manifest.pubkey })
: naddrEncode({ kind, pubkey: manifest.pubkey, identifier: appId });
const descriptions = manifest.tags.filter(tag => tag[0] === 'description');
const [legacyDescription] = descriptions;
const legacyText = descriptions.length === 1 && legacyDescription?.length === 2 ? legacyDescription[1]! : '';
const description = (manifest.format === 'legacy-path' ? legacyText : manifest.content)
.replace(DISPLAY_CONTROLS, '').trim().slice(0, 8192);
const icons = manifest.tags.filter(tag => tag[0] === 'icon');
const [icon] = icons;
const artwork = icons.length === 1 && icon?.length === 3 && /^[0-9a-f]{64}$/.test(icon[1]!) && IMAGE_MIME_TYPES.has(icon[2]!)
? Object.freeze({ hash: icon[1]!, mime: icon[2]!, serverHints: manifest.serverHints }) : undefined;
return Object.freeze({ title, description, ...(artwork && { icon: artwork }), reference, version, htmlHash });
}
3 changes: 2 additions & 1 deletion src/routing/routing-review.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ReviewBusyError, type ReviewSlot } from '../shell/review-slot.ts';
import type { NappletDescriptor } from '../shell/workspace.ts';
import type { VerifiedLaunchMetadata } from '../napplets/verified-launch-metadata.ts';
import type { NappletConsentReview } from '../napplets/first-open-consent.ts';
import type { NativeRegistration } from '../runtime/capability-protocol.ts';
import type { IntentRequest } from './routing-contract.ts';
Expand All @@ -9,7 +10,7 @@ export type RoutingReviewRequest = Readonly<{ kind: 'intent'; origin: NativeRegi
| Readonly<{ kind: 'channel'; origin: NativeRegistration; peer: NativeRegistration }>
| Readonly<{ kind: 'permissions'; origin: NativeRegistration; consent: NappletConsentReview }>
| Readonly<{ kind: 'published-open'; origin: NativeRegistration; source: NappletDescriptor; changesAccess: boolean; legacyReadOnlyAvailable?: true;
target: Readonly<{ publisher: string; appId: string; eventId: string; version: string;
target: Readonly<{ publisher: string; appId: string; eventId: string; version: string; metadata: VerifiedLaunchMetadata;
requiredDomains: readonly string[]; optionalDomains: readonly string[];
unsupportedDomains: readonly string[]; unsupportedOptionalDomains: readonly string[] }> }>;
export type RoutingDecision = Readonly<{ allow: boolean; selected?: CatalogHandler; remember?: boolean; legacyReadOnly?: true }>;
Expand Down
13 changes: 9 additions & 4 deletions src/security/trusted-identity-owner.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createLaunchReviewMedia, type LaunchReviewMedia } from '../napplets/launch-review-media';
import { createPublicPreferenceImport } from '../routing/public-preference-import';
import {withCvmRelayDefaults} from '../cvm/cvm-relay-defaults';
import {CvmService} from '../cvm/cvm-service';
Expand Down Expand Up @@ -61,6 +62,7 @@ import { assertWorkspaceAvailability, initialTestWorkspace } from '../shell/fixt

export interface TrustedIdentityOwner {
readonly profileAvatar?: ProfileAvatarStore;
readonly launchReviewMedia?: LaunchReviewMedia;
readonly notify?: NotifyBroker;
readonly notificationSettings?: NotificationSettingsService;
readonly config?: ConfigBroker;
Expand All @@ -82,7 +84,7 @@ export interface TrustedIdentityOwner {
export class WorkspaceStartupError extends Error {
constructor() { super('Workspace could not be opened'); this.name = 'WorkspaceStartupError'; }
}
async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android' | 'ios'): Promise<{ cvm:CvmService; profileAvatar: ProfileAvatarStore; notificationSettings: NotificationSettingsService; config: ConfigBroker; notify: NotifyBroker; routing: Readonly<{broker: RoutingBroker; catalog: RoutingCatalog; reviews: RoutingReviewService; settings: RoutingSettingsService}>; listAccess: IdentityListAccess | null; transition: IdentityTransition; runtime: RuntimeOwner; approvals: ApprovalService; relaySettings: NativeRelaySettings | null; resourceSettings: NativeResourceSettings | null; uploadSettings: NativeUploadSettings | null; uploads: UploadService; reviewSlot: ReviewSlot; fixturePublisher: FixturePublisher }> {
async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android' | 'ios'): Promise<{ cvm:CvmService; profileAvatar: ProfileAvatarStore; launchReviewMedia: LaunchReviewMedia; notificationSettings: NotificationSettingsService; config: ConfigBroker; notify: NotifyBroker; routing: Readonly<{broker: RoutingBroker; catalog: RoutingCatalog; reviews: RoutingReviewService; settings: RoutingSettingsService}>; listAccess: IdentityListAccess | null; transition: IdentityTransition; runtime: RuntimeOwner; approvals: ApprovalService; relaySettings: NativeRelaySettings | null; resourceSettings: NativeResourceSettings | null; uploadSettings: NativeUploadSettings | null; uploads: UploadService; reviewSlot: ReviewSlot; fixturePublisher: FixturePublisher }> {
const database = await openShellDatabase(SQLite, platform).catch(() => { throw new WorkspaceStartupError(); });
let relaySettings: NativeRelaySettings | null = null;
let resourceSettings: NativeResourceSettings | null = null;
Expand Down Expand Up @@ -141,18 +143,21 @@ async function openOwnerWorkspace(vault: NativeIdentityVault, platform: 'android
relays:() => relaySettings?.getSettings().networkRelays ?? [],slot});
const routing = Object.freeze({broker: routingBroker, catalog: routingCatalog, reviews, settings: createRoutingSettings(transition, routingNative, routingCatalog, routingBroker,publicPreferences)});
const identityData = createIdentityDataService(identityCache, queries, Date.now, listAccess ?? undefined);
const profileAvatar = createProfileAvatarStore(identityData, createProfileAvatarReader(requireNativeModule<NativePublishedHttpsPort>('HypergolicNappletHost')));
const readAvatar = createProfileAvatarReader(requireNativeModule<NativePublishedHttpsPort>('HypergolicNappletHost'));
const profileAvatar = createProfileAvatarStore(identityData, readAvatar);
const publishedSource = loadNativePublishedArtifactSource();
const launchReviewMedia = createLaunchReviewMedia(identityData, readAvatar, publishedSource, () => resourceSettings?.servers() ?? []);
releaseAvatar = bindProfileAvatarOwner(transition, profileAvatar);
runtime = createRuntimeOwner(database, transition, capability,
{ service: approvals, destinations: () => relaySettings?.getSettings().networkRelays ?? [], closeReview: id => slot.close(id) },
{ sqlite: SQLite, source: loadNativePublishedArtifactSource(), native: loadNativePublishedTransferPort(),
{ sqlite: SQLite, source: publishedSource, native: loadNativePublishedTransferPort(),
lookupRelays: () => relaySettings?.getSettings().lookupRelays ?? [],
networkRelays: () => relaySettings?.getSettings().networkRelays ?? [] },
queries,
loadNativeRelaySubscriptions(() => relaySettings?.getSettings().networkRelays ?? []),
loadNativeResourceHost(() => resourceSettings?.servers() ?? [], queries), uploads, identityData, routing, notifyBroker, configBroker, outbox, loadNativeOutboxSubscriptions(outbox), discovery,cvm);
const fixturePublisher=createNativeFixturePublisher(vault,transition);
return { cvm, profileAvatar, fixturePublisher, notificationSettings: createNotificationSettings(transition, routingNative, notifyBroker), config: configBroker, notify: notifyBroker, routing, listAccess, transition, approvals, relaySettings, resourceSettings, uploadSettings, uploads, reviewSlot, runtime };
return { cvm, profileAvatar, launchReviewMedia, fixturePublisher, notificationSettings: createNotificationSettings(transition, routingNative, notifyBroker), config: configBroker, notify: notifyBroker, routing, listAccess, transition, approvals, relaySettings, resourceSettings, uploadSettings, uploads, reviewSlot, runtime };
} catch {
cvm?.dispose(); releaseAvatar?.(); releaseLinks?.(); releaseRouting?.(); routingBroker?.stop(); notifyBroker?.stop(); configBroker?.stop(); reviewSlot?.dispose();
try { await notifyPermissions?.close(); } catch { /* Preserve the workspace failure. */ }
Expand Down
Loading
Loading