mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
did:web support, including our own internal kind
This commit is contained in:
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did FROM users WHERE handle = $1",
|
||||
"query": "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,6 +12,11 @@
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "migrated_to_pds",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -21,8 +26,9 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6"
|
||||
"hash": "9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5"
|
||||
}
|
||||
Generated
+10
@@ -929,6 +929,15 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "btree-range-map"
|
||||
version = "0.7.2"
|
||||
@@ -6175,6 +6184,7 @@ dependencies = [
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
@@ -55,6 +55,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
|
||||
bs58 = "0.5.1"
|
||||
[features]
|
||||
external-infra = []
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -9,6 +9,21 @@ So like... make the thing unique, make it cool.
|
||||
- [ ] Unique "brand" style both unauthed and authed
|
||||
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
|
||||
|
||||
### Passkeys and 2FA
|
||||
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
|
||||
|
||||
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
|
||||
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
|
||||
- [ ] WebAuthn registration challenge generation and attestation verification
|
||||
- [ ] TOTP secret generation with QR code setup flow
|
||||
- [ ] Backup codes (hashed, one-time use) with recovery flow
|
||||
- [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
|
||||
- [ ] Passkey-only account creation (no password)
|
||||
- [ ] Settings UI for managing passkeys, TOTP, backup codes
|
||||
- [ ] Trusted devices option (remember this browser)
|
||||
- [ ] Rate limit 2FA attempts
|
||||
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
|
||||
|
||||
### Delegated accounts
|
||||
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
|
||||
|
||||
@@ -26,20 +41,19 @@ Accounts controlled by other accounts rather than having their own password. Whe
|
||||
- [ ] Log all actions with both actor DID and controller DID
|
||||
- [ ] Audit log view for delegated account owners
|
||||
|
||||
### Passkeys and 2FA
|
||||
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
|
||||
### Migration tool
|
||||
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
|
||||
|
||||
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
|
||||
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
|
||||
- [ ] WebAuthn registration challenge generation and attestation verification
|
||||
- [ ] TOTP secret generation with QR code setup flow
|
||||
- [ ] Backup codes (hashed, one-time use) with recovery flow
|
||||
- [ ] OAuth authorize flow: password → 2FA (if enabled) → passkey (as alternative)
|
||||
- [ ] Passkey-only account creation (no password)
|
||||
- [ ] Settings UI for managing passkeys, TOTP, backup codes
|
||||
- [ ] Trusted devices option (remember this browser)
|
||||
- [ ] Rate limit 2FA attempts
|
||||
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
|
||||
- [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint
|
||||
- [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
|
||||
- [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
|
||||
- [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
|
||||
- [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
|
||||
- [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
|
||||
- [ ] Support `createAccount` with existing DID + service auth token
|
||||
- [ ] Progress tracking with resume capability
|
||||
- [ ] Scheduled automatic backups (CAR export)
|
||||
- [ ] One-click restore from backup
|
||||
|
||||
### Plugin system
|
||||
Extensible architecture allowing third-party plugins to add functionality, like minecraft mods or browser extensions.
|
||||
@@ -74,7 +88,9 @@ Records that only authorized parties can see and decrypt. Requires key federatio
|
||||
|
||||
## Completed
|
||||
|
||||
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports.
|
||||
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports.
|
||||
|
||||
did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, migration tracking for did:web users who leave (serviceEndpoint redirect), clear registration warnings about did:web trade-offs vs did:plc.
|
||||
|
||||
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
|
||||
|
||||
|
||||
@@ -71,11 +71,15 @@ export interface InviteCode {
|
||||
|
||||
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
|
||||
|
||||
export type DidType = 'plc' | 'web' | 'web-external'
|
||||
|
||||
export interface CreateAccountParams {
|
||||
handle: string
|
||||
email: string
|
||||
password: string
|
||||
inviteCode?: string
|
||||
didType?: DidType
|
||||
did?: string
|
||||
verificationChannel?: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
@@ -109,6 +113,8 @@ export const api = {
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
inviteCode: params.inviteCode,
|
||||
didType: params.didType,
|
||||
did: params.did,
|
||||
verificationChannel: params.verificationChannel,
|
||||
discordId: params.discordId,
|
||||
telegramUsername: params.telegramUsername,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { register, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError, type VerificationChannel } from '../lib/api'
|
||||
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
|
||||
|
||||
const STORAGE_KEY = 'tranquil_pds_pending_verification'
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
let discordId = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let didType = $state<DidType>('plc')
|
||||
let externalDid = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let serverInfo = $state<{
|
||||
@@ -56,6 +58,10 @@
|
||||
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
|
||||
return 'Invite code is required'
|
||||
}
|
||||
if (didType === 'web-external') {
|
||||
if (!externalDid.trim()) return 'External did:web is required'
|
||||
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
|
||||
}
|
||||
switch (verificationChannel) {
|
||||
case 'email':
|
||||
if (!email.trim()) return 'Email is required for email verification'
|
||||
@@ -88,6 +94,8 @@
|
||||
email: email.trim(),
|
||||
password,
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : undefined,
|
||||
verificationChannel,
|
||||
discordId: discordId.trim() || undefined,
|
||||
telegramUsername: telegramUsername.trim() || undefined,
|
||||
@@ -171,6 +179,76 @@
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<fieldset class="identity-section">
|
||||
<legend>Identity Type</legend>
|
||||
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="didType"
|
||||
value="plc"
|
||||
bind:group={didType}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span class="radio-content">
|
||||
<strong>did:plc</strong> (Recommended)
|
||||
<span class="radio-hint">Portable identity managed by PLC Directory</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="didType"
|
||||
value="web"
|
||||
bind:group={didType}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span class="radio-content">
|
||||
<strong>did:web</strong>
|
||||
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="didType"
|
||||
value="web-external"
|
||||
bind:group={didType}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span class="radio-content">
|
||||
<strong>did:web (BYOD)</strong>
|
||||
<span class="radio-hint">Bring your own domain</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if didType === 'web'}
|
||||
<div class="did-web-warning">
|
||||
<strong>Important: Understand the trade-offs</strong>
|
||||
<ul>
|
||||
<li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li>
|
||||
<li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li>
|
||||
<li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li>
|
||||
<li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">Your did:web</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={externalDid}
|
||||
placeholder="did:web:yourdomain.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<fieldset class="verification-section">
|
||||
<legend>Contact Method</legend>
|
||||
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
|
||||
@@ -323,11 +401,73 @@
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.identity-section {
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.identity-section legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio-label input[type="radio"] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.radio-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.radio-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.did-web-warning {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--warning-bg, #fff3cd);
|
||||
border: 1px solid var(--warning-border, #ffc107);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.did-web-warning strong {
|
||||
color: var(--warning-text, #856404);
|
||||
}
|
||||
.did-web-warning ul {
|
||||
margin: 0.75rem 0 0 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.did-web-warning li {
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.did-web-warning li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.did-web-warning code {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN migrated_to_pds TEXT;
|
||||
ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
|
||||
+115
-71
@@ -41,6 +41,7 @@ pub struct CreateAccountInput {
|
||||
pub password: String,
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub did_type: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
@@ -268,44 +269,35 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let did = if let Some(d) = &input.did {
|
||||
if d.trim().is_empty() {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&full_handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
let did_type = input.did_type.as_deref().unwrap_or("plc");
|
||||
let did = match did_type {
|
||||
"web" => {
|
||||
let subdomain_host = format!("{}.{}", input.handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
|
||||
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
|
||||
self_hosted_did
|
||||
}
|
||||
"web-external" => {
|
||||
let d = match &input.did {
|
||||
Some(d) if !d.trim().is_empty() => d,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
if !d.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
} else if d.starts_with("did:web:") {
|
||||
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -313,52 +305,104 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web account");
|
||||
d.clone()
|
||||
} else if d.starts_with("did:plc:") && is_migration {
|
||||
d.clone()
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&full_handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
.into_response();
|
||||
_ => {
|
||||
if let Some(d) = &input.did {
|
||||
if d.starts_with("did:plc:") && is_migration {
|
||||
info!(did = %d, "Migration with existing did:plc");
|
||||
d.clone()
|
||||
} else if d.starts_with("did:web:") {
|
||||
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
d.clone()
|
||||
} else if !d.trim().is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
|
||||
)
|
||||
.into_response();
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&full_handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
}
|
||||
} else {
|
||||
let rotation_key = std::env::var("PLC_ROTATION_KEY")
|
||||
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
|
||||
let genesis_result = match create_genesis_operation(
|
||||
&signing_key,
|
||||
&rotation_key,
|
||||
&full_handle,
|
||||
&pds_endpoint,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Error creating PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to register DID with PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
|
||||
genesis_result.did
|
||||
};
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
|
||||
+233
-70
@@ -95,9 +95,32 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
|
||||
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
|
||||
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
|
||||
let public_key = secret_key.public_key();
|
||||
let compressed = public_key.to_encoded_point(true);
|
||||
let compressed_bytes = compressed.as_bytes();
|
||||
let mut multicodec_key = vec![0xe7, 0x01];
|
||||
multicodec_key.extend_from_slice(compressed_bytes);
|
||||
Ok(format!("z{}", bs58::encode(&multicodec_key).into_string()))
|
||||
}
|
||||
|
||||
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
// Kinda for local dev, encode hostname if it contains port
|
||||
let host_header = headers
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or(&hostname);
|
||||
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
|
||||
let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
|
||||
if host_without_port != hostname_without_port
|
||||
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
|
||||
{
|
||||
let handle = host_without_port
|
||||
.strip_suffix(&format!(".{}", hostname_without_port))
|
||||
.unwrap_or(host_without_port);
|
||||
return serve_subdomain_did_doc(&state, handle, &hostname).await;
|
||||
}
|
||||
let did = if hostname.contains(':') {
|
||||
format!("did:web:{}", hostname.replace(':', "%3A"))
|
||||
} else {
|
||||
@@ -112,15 +135,18 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
|
||||
"serviceEndpoint": format!("https://{}", hostname)
|
||||
}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, did) = match user {
|
||||
Ok(Some(row)) => (row.id, row.did),
|
||||
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
|
||||
handle
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, did, migrated_to_pds) = match user {
|
||||
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
|
||||
}
|
||||
@@ -140,6 +166,16 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let subdomain_host = format!("{}.{}", handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
|
||||
if did != expected_self_hosted {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
@@ -165,10 +201,10 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let jwk = match get_jwk(&key_bytes) {
|
||||
Ok(j) => j,
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate JWK: {}", e);
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -176,25 +212,148 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let full_handle = if handle.contains('.') {
|
||||
handle.to_string()
|
||||
} else {
|
||||
format!("{}.{}", handle, hostname)
|
||||
};
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
Json(json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": [format!("at://{}", handle)],
|
||||
"alsoKnownAs": [format!("at://{}", full_handle)],
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "JsonWebKey2020",
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyJwk": jwk
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": format!("https://{}", hostname)
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
})).into_response()
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
|
||||
handle
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let (user_id, did, migrated_to_pds) = match user {
|
||||
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
|
||||
Ok(None) => {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB Error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let encoded_hostname = hostname.replace(':', "%3A");
|
||||
let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
|
||||
let subdomain_host = format!("{}.{}", handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
|
||||
if did != old_path_format && did != new_subdomain_format {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let key_bytes: Vec<u8> = match key_row {
|
||||
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to generate public key multibase: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let full_handle = if handle.contains('.') {
|
||||
handle.clone()
|
||||
} else {
|
||||
format!("{}.{}", handle, hostname)
|
||||
};
|
||||
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
|
||||
Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": [format!("at://{}", full_handle)],
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
|
||||
let subdomain_host = format!("{}.{}", handle, hostname);
|
||||
let encoded_subdomain = subdomain_host.replace(':', "%3A");
|
||||
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
|
||||
if did == expected_subdomain_did {
|
||||
return Ok(());
|
||||
}
|
||||
let expected_prefix = if hostname.contains(':') {
|
||||
format!("did:web:{}", hostname.replace(':', "%3A"))
|
||||
} else {
|
||||
@@ -204,62 +363,61 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
|
||||
let suffix = &did[expected_prefix.len()..];
|
||||
let expected_suffix = format!(":u:{}", handle);
|
||||
if suffix == expected_suffix {
|
||||
Ok(())
|
||||
return Ok(());
|
||||
} else {
|
||||
Err(format!(
|
||||
return Err(format!(
|
||||
"Invalid DID path for this PDS. Expected {}",
|
||||
expected_suffix
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
let parts: Vec<&str> = did.split(':').collect();
|
||||
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
|
||||
return Err("Invalid did:web format".into());
|
||||
}
|
||||
let domain_segment = parts[2];
|
||||
let domain = domain_segment.replace("%3A", ":");
|
||||
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
|
||||
"http"
|
||||
} else {
|
||||
let parts: Vec<&str> = did.split(':').collect();
|
||||
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
|
||||
return Err("Invalid did:web format".into());
|
||||
}
|
||||
let domain_segment = parts[2];
|
||||
let domain = domain_segment.replace("%3A", ":");
|
||||
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let url = if parts.len() == 3 {
|
||||
format!("{}://{}/.well-known/did.json", scheme, domain)
|
||||
} else {
|
||||
let path = parts[3..].join("/");
|
||||
format!("{}://{}/{}/did.json", scheme, domain, path)
|
||||
};
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create client: {}", e))?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
|
||||
}
|
||||
let doc: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
|
||||
let services = doc["service"]
|
||||
.as_array()
|
||||
.ok_or("No services found in DID doc")?;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let has_valid_service = services.iter().any(|s| {
|
||||
s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint
|
||||
});
|
||||
if has_valid_service {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
|
||||
pds_endpoint
|
||||
))
|
||||
}
|
||||
"https"
|
||||
};
|
||||
let url = if parts.len() == 3 {
|
||||
format!("{}://{}/.well-known/did.json", scheme, domain)
|
||||
} else {
|
||||
let path = parts[3..].join("/");
|
||||
format!("{}://{}/{}/did.json", scheme, domain, path)
|
||||
};
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create client: {}", e))?;
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
|
||||
}
|
||||
let doc: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
|
||||
let services = doc["service"]
|
||||
.as_array()
|
||||
.ok_or("No services found in DID doc")?;
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
let has_valid_service = services
|
||||
.iter()
|
||||
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
|
||||
if has_valid_service {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
|
||||
pds_endpoint
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,10 +502,15 @@ pub async fn get_recommended_did_credentials(
|
||||
Err(_) => return ApiError::InternalError.into_response(),
|
||||
};
|
||||
let did_key = signing_key_to_did_key(&signing_key);
|
||||
let rotation_keys = if auth_user.did.starts_with("did:web:") {
|
||||
vec![]
|
||||
} else {
|
||||
vec![did_key.clone()]
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetRecommendedDidCredentialsOutput {
|
||||
rotation_keys: vec![did_key.clone()],
|
||||
rotation_keys,
|
||||
also_known_as: vec![format!("at://{}", full_handle)],
|
||||
verification_methods: VerificationMethods { atproto: did_key },
|
||||
services: Services {
|
||||
|
||||
@@ -63,6 +63,12 @@ pub async fn sign_plc_operation(
|
||||
return e;
|
||||
}
|
||||
let did = &auth_user.did;
|
||||
if did.starts_with("did:web:") {
|
||||
return ApiError::InvalidRequest(
|
||||
"PLC operations are only valid for did:plc identities".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token = match &input.token {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
|
||||
@@ -42,6 +42,12 @@ pub async fn submit_plc_operation(
|
||||
return e;
|
||||
}
|
||||
let did = &auth_user.did;
|
||||
if did.starts_with("did:web:") {
|
||||
return ApiError::InvalidRequest(
|
||||
"PLC operations are only valid for did:plc identities".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = validate_plc_operation(&input.operation) {
|
||||
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_self_hosted_did_web() {
|
||||
let client = client();
|
||||
let handle = format!("selfweb_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
if res.status() != StatusCode::OK {
|
||||
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
|
||||
panic!("createAccount failed: {:?}", body);
|
||||
}
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let did = body["did"].as_str().expect("No DID in response");
|
||||
assert!(
|
||||
did.starts_with("did:web:"),
|
||||
"DID should start with did:web:, got: {}",
|
||||
did
|
||||
);
|
||||
assert!(
|
||||
did.contains(&handle),
|
||||
"DID should contain handle {}, got: {}",
|
||||
handle,
|
||||
did
|
||||
);
|
||||
assert!(
|
||||
!did.contains(":u:"),
|
||||
"Self-hosted did:web should use subdomain format (no :u:), got: {}",
|
||||
did
|
||||
);
|
||||
let jwt = verify_new_account(&client, did).await;
|
||||
let res = client
|
||||
.get(format!("{}/u/{}/did.json", base_url().await, handle))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch DID doc via path");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)"
|
||||
);
|
||||
let doc: Value = res.json().await.expect("DID doc was not JSON");
|
||||
assert_eq!(doc["id"], did);
|
||||
assert!(
|
||||
doc["verificationMethod"][0]["publicKeyMultibase"].is_string(),
|
||||
"DID doc should have publicKeyMultibase"
|
||||
);
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.createRecord",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello from did:web!",
|
||||
"createdAt": chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create post");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::OK,
|
||||
"Self-hosted did:web account should be able to create records"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_did_web_no_local_doc() {
|
||||
let client = client();
|
||||
let mock_server = MockServer::start().await;
|
||||
let mock_uri = mock_server.uri();
|
||||
let mock_addr = mock_uri.trim_start_matches("http://");
|
||||
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
|
||||
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
|
||||
let pds_endpoint = base_url().await.replace("http://", "https://");
|
||||
let did_doc = json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": pds_endpoint
|
||||
}]
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/did.json"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web-external",
|
||||
"did": did
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
if res.status() != StatusCode::OK {
|
||||
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
|
||||
panic!("createAccount failed: {:?}", body);
|
||||
}
|
||||
let res = client
|
||||
.get(format!("{}/u/{}/did.json", base_url().await, handle))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch DID doc");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"External did:web should NOT have DID doc served by PDS"
|
||||
);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
assert!(
|
||||
body["message"].as_str().unwrap_or("").contains("External"),
|
||||
"Error message should indicate external did:web"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plc_operations_blocked_for_did_web() {
|
||||
let client = client();
|
||||
let handle = format!("plcblock_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let did = body["did"].as_str().expect("No DID").to_string();
|
||||
let jwt = verify_new_account(&client, &did).await;
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.identity.signPlcOperation",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"token": "fake-token"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"signPlcOperation should be blocked for did:web users"
|
||||
);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
assert!(
|
||||
body["message"].as_str().unwrap_or("").contains("did:plc"),
|
||||
"Error should mention did:plc: {:?}",
|
||||
body
|
||||
);
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.identity.submitPlcOperation",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"operation": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"submitPlcOperation should be blocked for did:web users"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
|
||||
let client = client();
|
||||
let handle = format!("creds_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let did = body["did"].as_str().expect("No DID").to_string();
|
||||
let jwt = verify_new_account(&client, &did).await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let rotation_keys = body["rotationKeys"]
|
||||
.as_array()
|
||||
.expect("rotationKeys should be an array");
|
||||
assert!(
|
||||
rotation_keys.is_empty(),
|
||||
"did:web should have no rotation keys, got: {:?}",
|
||||
rotation_keys
|
||||
);
|
||||
assert!(
|
||||
body["verificationMethods"].is_object(),
|
||||
"verificationMethods should be present"
|
||||
);
|
||||
assert!(body["services"].is_object(), "services should be present");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_did_plc_still_works_with_did_type_param() {
|
||||
let client = client();
|
||||
let handle = format!("plctype_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "plc"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
let did = body["did"].as_str().expect("No DID").to_string();
|
||||
assert!(
|
||||
did.starts_with("did:plc:"),
|
||||
"DID with didType=plc should be did:plc:, got: {}",
|
||||
did
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_did_web_requires_did_field() {
|
||||
let client = client();
|
||||
let handle = format!("nodid_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web-external"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
base_url().await
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"web-external without did should fail"
|
||||
);
|
||||
let body: Value = res.json().await.expect("Response was not JSON");
|
||||
assert!(
|
||||
body["message"].as_str().unwrap_or("").contains("did"),
|
||||
"Error should mention did field is required: {:?}",
|
||||
body
|
||||
);
|
||||
}
|
||||
+5
-6
@@ -143,12 +143,11 @@ async fn test_create_did_web_account_and_resolve() {
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to fetch DID doc");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let doc: Value = res.json().await.expect("DID doc was not JSON");
|
||||
assert_eq!(doc["id"], did);
|
||||
assert_eq!(doc["alsoKnownAs"][0], format!("at://{}", handle));
|
||||
assert_eq!(doc["verificationMethod"][0]["controller"], did);
|
||||
assert!(doc["verificationMethod"][0]["publicKeyJwk"].is_object());
|
||||
assert_eq!(
|
||||
res.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"External did:web should not have DID doc served by PDS (user hosts their own)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user