Establish Coulomb scoped recovery API custody and native boundaries
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a17f16-d99c-7ea3-95f0-8212897fba6b
This commit is contained in:
parent
ccc0f8b578
commit
849f3947dc
8 changed files with 784 additions and 13 deletions
45
docs/credential-lane-designs/coulomb-recovery-api.json
Normal file
45
docs/credential-lane-designs/coulomb-recovery-api.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"schema": "platform.coulomb-recovery-api.v1",
|
||||
"project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f",
|
||||
"region": "nl-ams",
|
||||
"custody_owner": "railiance-platform/platform-root",
|
||||
"public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3",
|
||||
"materials": {
|
||||
"age": {
|
||||
"id": "634e4b9f-0b61-4329-a83c-1ed0e76c4636",
|
||||
"revision": 1,
|
||||
"name": "coulomb-social-age-recovery"
|
||||
},
|
||||
"reader-pair": {
|
||||
"id": "241742f7-d3b5-4163-8d87-3a3d9585778d",
|
||||
"revision": 1,
|
||||
"name": "coulomb-social-recovery-reader-api"
|
||||
},
|
||||
"custodian-pair": {
|
||||
"id": "cf44e9bd-9edc-450b-9f10-564ee3008305",
|
||||
"revision": 1,
|
||||
"name": "coulomb-social-recovery-custodian-api"
|
||||
}
|
||||
},
|
||||
"applications": {
|
||||
"custodian": "f80037a2-87c9-44d2-9163-3e6907af3ced",
|
||||
"reader": "7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"routine_key_expirations": {
|
||||
"reader": "2027-10-28T19:24:59.647270Z",
|
||||
"custodian": "2027-10-28T19:25:00.458446Z"
|
||||
},
|
||||
"buckets": {
|
||||
"kits": {
|
||||
"name": "railiance-coulomb-social-recovery",
|
||||
"prefix": "kits/",
|
||||
"automatic_expiry_enabled": false
|
||||
},
|
||||
"archives": {
|
||||
"name": "railiance-coulomb-social-archives",
|
||||
"prefix": "archives/",
|
||||
"automatic_expiry_enabled": false
|
||||
}
|
||||
},
|
||||
"native_application_recovery_verified": false
|
||||
}
|
||||
53
docs/credential-lane-designs/coulomb-recovery-api.md
Normal file
53
docs/credential-lane-designs/coulomb-recovery-api.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Coulomb recovery API custody
|
||||
|
||||
Owner: railiance-platform / platform-root. Admission decisions
|
||||
27bbc1ac-e275-42be-ba34-6cf16eae4e78 and 6139e02b-3fb7-4eff-be8a-efe127c45ce5.
|
||||
Non-secret coordinates: coulomb-recovery-api.json. Native evidence:
|
||||
../evidence/2026-10-29-coulomb-recovery-api-bootstrap.json.
|
||||
|
||||
Routine access uses separate reader/custodian API pairs under an owner-private
|
||||
directory outside Git, normally ~/.config/railiance-platform/coulomb-social-recovery/.
|
||||
The worker holds only its original publisher pair and the dedicated public age
|
||||
recipient. Never deliver the custodian pair, reader pair or private age identity
|
||||
to that worker. No sibling credentials or private keys are reused.
|
||||
|
||||
Check independent retrieval without writing a private identity:
|
||||
|
||||
```sh
|
||||
python3 scripts/application_recovery_access.py --execute --material age
|
||||
```
|
||||
|
||||
For an isolated attended restore, add --output with a fresh absolute filename
|
||||
under an existing owner-private directory outside Git. The helper validates exact
|
||||
project/name/protection/revision/nonexpiry and public recipient before writing
|
||||
mode0600. Materials reader-pair and custodian-pair use the same front door;
|
||||
omit --output to verify retrieval without creating another secret copy.
|
||||
No value goes to stdout, Git, State Hub, shell arguments or environment.
|
||||
|
||||
The reader performs only prefix/version reads and listing in the isolated project.
|
||||
The custodian can also publish new kit versions and read the dedicated escrow;
|
||||
neither can delete retained versions or administer buckets/IAM. Scaleway's current
|
||||
2023-04-17 bucket policies intersect IAM with an explicit allowlist. Deletion is
|
||||
absent while publisher multipart abort is included; native checks prove both.
|
||||
Negative PUT probes use separate transports because a denied upload can leave
|
||||
the provider connection unusable for its next request.
|
||||
|
||||
Both buckets are private and versioned; no automatic expiry has been enabled.
|
||||
The protected bucket never participates in rolling expiry. Rolling retention,
|
||||
capacity, first coherent app capture, independent exact-version decryption and
|
||||
isolated application recovery, monitoring and timers remain WP-0053/CSOC-WP-0012
|
||||
acceptance work. Synthetic fixtures are boundary evidence, not app backups.
|
||||
|
||||
The routine pairs and age identity each have a separate protected, enabled,
|
||||
nonexpiring provider secret version. They survive node/workstation loss. If all
|
||||
local routine credentials are lost, platform-root uses its existing independent
|
||||
account/MFA recovery to retrieve only the custodian escrow coordinate in the
|
||||
public contract, then returns to the API front door. Emergency owner recovery is
|
||||
separate from routine drill access; preserve the platform-root recovery mechanism.
|
||||
|
||||
Routine API keys expire on 2027-10-28; exact timestamps are in the JSON contract.
|
||||
Platform-root owns renewal and independently read-back escrow before those
|
||||
deadlines, using a bounded attended construction window. No standing organization
|
||||
administration key or publisher IAM administration is admitted. The construction
|
||||
key was revoked, its temporary pair removed and the personal browser context
|
||||
closed without saving state. Routine drills do not rerun identity bootstrap.
|
||||
445
docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json
Normal file
445
docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
{
|
||||
"programmatic_direction_reference": "6139e02b-3fb7-4eff-be8a-efe127c45ce5",
|
||||
"applications": {
|
||||
"bootstrap": "2a355842-e03b-42a3-8af7-6cd8f8b98bcd",
|
||||
"custodian": "f80037a2-87c9-44d2-9163-3e6907af3ced",
|
||||
"reader": "7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"automatic_expiry_enabled": false,
|
||||
"bootstrap_key_revoked_verified": true,
|
||||
"buckets": {
|
||||
"railiance-coulomb-social-archives": {
|
||||
"created": true,
|
||||
"lifecycle_rules": [],
|
||||
"policy": {
|
||||
"Id": "coulomb-independent-recovery",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": "s3:*",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "user_id:3d029efb-4f70-4f82-a3bb-cefbe255232b"
|
||||
},
|
||||
"Resource": [
|
||||
"railiance-coulomb-social-archives",
|
||||
"railiance-coulomb-social-archives/*"
|
||||
],
|
||||
"Sid": "PlatformOwner"
|
||||
},
|
||||
{
|
||||
"Action": "s3:*",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:2a355842-e03b-42a3-8af7-6cd8f8b98bcd"
|
||||
},
|
||||
"Resource": [
|
||||
"railiance-coulomb-social-archives",
|
||||
"railiance-coulomb-social-archives/*"
|
||||
],
|
||||
"Sid": "TemporaryConstruction"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives/archives/*",
|
||||
"Sid": "readerObjects"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": [
|
||||
"archives/",
|
||||
"archives/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives",
|
||||
"Sid": "readerList"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives/archives/*",
|
||||
"Sid": "custodianObjects"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": [
|
||||
"archives/",
|
||||
"archives/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives",
|
||||
"Sid": "custodianList"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject",
|
||||
"s3:AbortMultipartUpload",
|
||||
"s3:ListMultipartUploadParts"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:681d00bf-b965-4323-b657-97bfde423511"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives/archives/*",
|
||||
"Sid": "publisherObjects"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": [
|
||||
"archives/",
|
||||
"archives/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:681d00bf-b965-4323-b657-97bfde423511"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives",
|
||||
"Sid": "publisherList"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:GetLifecycleConfiguration"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": [
|
||||
"application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de",
|
||||
"application_id:f80037a2-87c9-44d2-9163-3e6907af3ced",
|
||||
"application_id:681d00bf-b965-4323-b657-97bfde423511"
|
||||
]
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-archives",
|
||||
"Sid": "ArchiveMetadata"
|
||||
}
|
||||
],
|
||||
"Version": "2023-04-17"
|
||||
},
|
||||
"public_acl_absent": true,
|
||||
"versioning": "Enabled"
|
||||
},
|
||||
"railiance-coulomb-social-recovery": {
|
||||
"created": true,
|
||||
"lifecycle_rules": [],
|
||||
"policy": {
|
||||
"Id": "coulomb-independent-recovery",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": "s3:*",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "user_id:3d029efb-4f70-4f82-a3bb-cefbe255232b"
|
||||
},
|
||||
"Resource": [
|
||||
"railiance-coulomb-social-recovery",
|
||||
"railiance-coulomb-social-recovery/*"
|
||||
],
|
||||
"Sid": "PlatformOwner"
|
||||
},
|
||||
{
|
||||
"Action": "s3:*",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:2a355842-e03b-42a3-8af7-6cd8f8b98bcd"
|
||||
},
|
||||
"Resource": [
|
||||
"railiance-coulomb-social-recovery",
|
||||
"railiance-coulomb-social-recovery/*"
|
||||
],
|
||||
"Sid": "TemporaryConstruction"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery/kits/*",
|
||||
"Sid": "readerObjects"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": [
|
||||
"kits/",
|
||||
"kits/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery",
|
||||
"Sid": "readerList"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:GetLifecycleConfiguration"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery",
|
||||
"Sid": "readerMetadata"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectVersion",
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery/kits/*",
|
||||
"Sid": "custodianObjects"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket",
|
||||
"s3:ListBucketVersions"
|
||||
],
|
||||
"Condition": {
|
||||
"StringLike": {
|
||||
"s3:prefix": [
|
||||
"kits/",
|
||||
"kits/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery",
|
||||
"Sid": "custodianList"
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:GetBucketVersioning",
|
||||
"s3:GetLifecycleConfiguration"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced"
|
||||
},
|
||||
"Resource": "railiance-coulomb-social-recovery",
|
||||
"Sid": "custodianMetadata"
|
||||
}
|
||||
],
|
||||
"Version": "2023-04-17"
|
||||
},
|
||||
"public_acl_absent": true,
|
||||
"versioning": "Enabled"
|
||||
}
|
||||
},
|
||||
"completed_at": "2026-10-29T20:59:12.852425+00:00",
|
||||
"decision": "27bbc1ac-e275-42be-ba34-6cf16eae4e78",
|
||||
"native_application_recovery_verified": false,
|
||||
"native_checks": {
|
||||
"archives_custodian_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_custodian_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_custodian_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_publisher_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_publisher_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_publisher_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_reader_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_reader_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_reader_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_reader_put": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"archives_reader_version_get_hash": {
|
||||
"verified": true,
|
||||
"version_id": "1793307392673579"
|
||||
},
|
||||
"custodian_iam_admin": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_custodian_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_custodian_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_custodian_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_publisher_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_publisher_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_publisher_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_reader_delete": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_reader_outside_prefix": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_reader_policy_write": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_reader_put": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"kits_reader_version_get_hash": {
|
||||
"verified": true,
|
||||
"version_id": "1793307384956710"
|
||||
},
|
||||
"publisher_create_readback": {
|
||||
"verified": true
|
||||
},
|
||||
"publisher_escrow": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"publisher_iam_admin": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"publisher_own_multipart_abort": {
|
||||
"verified": true
|
||||
},
|
||||
"publisher_protected_kit_get": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"reader_escrow": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
},
|
||||
"reader_iam_admin": {
|
||||
"denied": true,
|
||||
"http_status": 403
|
||||
}
|
||||
},
|
||||
"organization_wide_grants": false,
|
||||
"personal_browser_context_closed": true,
|
||||
"personal_state_saved": false,
|
||||
"private_values_printed": false,
|
||||
"project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f",
|
||||
"project_only_policies_verified": 4,
|
||||
"protected_secret_references": {
|
||||
"coulomb-social-age-recovery": {
|
||||
"id": "634e4b9f-0b61-4329-a83c-1ed0e76c4636",
|
||||
"revision": 1
|
||||
},
|
||||
"coulomb-social-recovery-access-canary": {
|
||||
"id": "db825503-cf12-4bfd-8cc5-f16d09edd8b5",
|
||||
"revision": 1
|
||||
},
|
||||
"coulomb-social-recovery-custodian-api": {
|
||||
"id": "cf44e9bd-9edc-450b-9f10-564ee3008305",
|
||||
"revision": 1
|
||||
},
|
||||
"coulomb-social-recovery-reader-api": {
|
||||
"id": "241742f7-d3b5-4163-8d87-3a3d9585778d",
|
||||
"revision": 1
|
||||
}
|
||||
},
|
||||
"public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3",
|
||||
"publisher_pair_replaced": false,
|
||||
"routine_age_and_both_pairs_retrieved_without_personal_session": true,
|
||||
"schema": "platform.coulomb-recovery-bootstrap.v1",
|
||||
"temporary_pair_removed": true,
|
||||
"timers_activated": false
|
||||
}
|
||||
89
scripts/application_recovery_access.py
Normal file
89
scripts/application_recovery_access.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Routine Coulomb recovery access; scoped custodian API, no Console session."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from application_recovery_identities import Refused, private_directory, private_json
|
||||
from application_recovery_resources import API, SECRET_BASE, public_recipient, status
|
||||
|
||||
CONTRACT = Path(__file__).resolve().parents[1] / "docs/credential-lane-designs/coulomb-recovery-api.json"
|
||||
PROJECT = "06d107bc-9d84-4672-b2a0-95701cc0c07f"
|
||||
|
||||
|
||||
def retrieve(api, contract, material):
|
||||
if (contract.get("schema") != "platform.coulomb-recovery-api.v1"
|
||||
or contract.get("project_id") != PROJECT):
|
||||
raise Refused("exact_coulomb_contract_required")
|
||||
reference = contract["materials"][material]
|
||||
path = SECRET_BASE + "/" + reference["id"]
|
||||
metadata = api("GET", path)
|
||||
if (metadata.get("id") != reference["id"] or metadata.get("name") != reference["name"]
|
||||
or metadata.get("project_id") != PROJECT or metadata.get("protected") is not True
|
||||
or metadata.get("type") != "opaque" or metadata.get("ephemeral_policy") is not None
|
||||
or metadata.get("deletion_requested_at") is not None):
|
||||
raise Refused("protected_exact_escrow_required")
|
||||
version = api("GET", path + "/versions/" + str(reference["revision"]))
|
||||
properties = version.get("ephemeral_properties") or {}
|
||||
if (version.get("status") != "enabled" or properties.get("expires_at")
|
||||
or properties.get("expires_once_accessed")):
|
||||
raise Refused("enabled_nonexpiring_exact_version_required")
|
||||
response = api("GET", path + "/versions/" + str(reference["revision"]) + "/access")
|
||||
if response.get("secret_id") != reference["id"] or response.get("revision") != reference["revision"]:
|
||||
raise Refused("escrow_response_binding_refused")
|
||||
payload = base64.b64decode(response["data"], validate=True)
|
||||
if material == "age":
|
||||
if public_recipient(payload) != contract["public_recipient"]:
|
||||
raise Refused("public_recipient_differs")
|
||||
else:
|
||||
pair = json.loads(payload)
|
||||
if set(pair) != {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"} or any(
|
||||
not isinstance(v, str) or not v or "\n" in v for v in pair.values()
|
||||
):
|
||||
raise Refused("exact_api_pair_required")
|
||||
return payload
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--directory", type=Path, default=Path.home() / ".config/railiance-platform/coulomb-social-recovery")
|
||||
parser.add_argument("--material", choices=("age", "reader-pair", "custodian-pair"), default="age")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not args.execute:
|
||||
print(json.dumps({"status": "preview", "material": args.material, "provider_contacted": False,
|
||||
"personal_login_required": False}))
|
||||
return
|
||||
private_directory(args.directory)
|
||||
if args.output:
|
||||
if not args.output.is_absolute():
|
||||
raise Refused("absolute_private_output_required")
|
||||
private_directory(args.output.parent)
|
||||
if args.output.exists() or args.output.is_symlink():
|
||||
raise Refused("fresh_private_output_required")
|
||||
contract = json.loads(CONTRACT.read_text())
|
||||
pair = private_json(args.directory / "custodian-pair.json")
|
||||
payload = retrieve(API(pair), contract, args.material)
|
||||
if args.output:
|
||||
fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
print(json.dumps({"status": "scoped_api_retrieval_verified", "material": args.material,
|
||||
"private_output_created": bool(args.output), "credential_values_printed": False,
|
||||
"personal_session_used": False}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error: # noqa: BLE001 -- suppress secret-bearing provider diagnostics
|
||||
print(json.dumps({"status": "refused", "failure_type": type(error).__name__,
|
||||
"native_status": status(error)}))
|
||||
sys.exit(1)
|
||||
|
|
@ -7,6 +7,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import base64
|
||||
import fcntl
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
|
@ -180,6 +181,16 @@ class Resources:
|
|||
def save(self):
|
||||
checkpoint(self.path, self.state)
|
||||
|
||||
def deny_s3(self, role, operation, **arguments):
|
||||
# Some denied PUTs leave a provider keep-alive connection unusable for
|
||||
# the next request (observed HTTP400 with an empty error). Isolate each
|
||||
# negative probe so it cannot corrupt subsequent positive read proof.
|
||||
client = s3(self.pairs[role], self.project)
|
||||
try:
|
||||
return denied(lambda: getattr(client, operation)(**arguments))
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def mutate(self, label, call):
|
||||
self.state["pending"] = label
|
||||
self.save()
|
||||
|
|
@ -360,21 +371,20 @@ class Resources:
|
|||
raise Refused("independent_version_listing_required")
|
||||
proofs[prefix + "_reader_version_get_hash"] = {"verified": True, "version_id": version}
|
||||
for role in ("reader", "custodian", "publisher"):
|
||||
client = self.clients[role]
|
||||
# All deletion targets are newly made synthetic canaries. No
|
||||
# actual retained backup is ever a destructive negative probe.
|
||||
proofs[prefix + "_" + role + "_delete"] = denied(lambda client=client, bucket=bucket, key=key, version=version: client.delete_object(
|
||||
Bucket=bucket, Key=key, VersionId=version))
|
||||
proofs[prefix + "_" + role + "_delete"] = self.deny_s3(role, "delete_object",
|
||||
Bucket=bucket, Key=key, VersionId=version)
|
||||
intended = self.state["buckets"][bucket]["policy"]
|
||||
proofs[prefix + "_" + role + "_policy_write"] = denied(lambda client=client, bucket=bucket, intended=intended: client.put_bucket_policy(
|
||||
Bucket=bucket, Policy=json.dumps(intended)))
|
||||
proofs[prefix + "_" + role + "_outside_prefix"] = denied(lambda client=client, bucket=bucket: client.get_object(
|
||||
Bucket=bucket, Key="outside-approved-prefix/synthetic-absent"))
|
||||
proofs[prefix + "_reader_put"] = denied(lambda reader=reader, bucket=bucket, prefix=prefix: reader.put_object(
|
||||
Bucket=bucket, Key=prefix + "/reader-write-denial.txt", Body=b"synthetic-only"))
|
||||
proofs[prefix + "_" + role + "_policy_write"] = self.deny_s3(role, "put_bucket_policy",
|
||||
Bucket=bucket, Policy=json.dumps(intended))
|
||||
proofs[prefix + "_" + role + "_outside_prefix"] = self.deny_s3(role, "get_object",
|
||||
Bucket=bucket, Key="outside-approved-prefix/synthetic-absent")
|
||||
proofs[prefix + "_reader_put"] = self.deny_s3("reader", "put_object",
|
||||
Bucket=bucket, Key=prefix + "/reader-write-denial.txt", Body=b"synthetic-only")
|
||||
kit = self.state["fixtures"]["kits"]
|
||||
proofs["publisher_protected_kit_get"] = denied(lambda: self.clients["publisher"].get_object(
|
||||
Bucket=kit["bucket"], Key=kit["key"], VersionId=kit["version_id"]))
|
||||
proofs["publisher_protected_kit_get"] = self.deny_s3("publisher", "get_object",
|
||||
Bucket=kit["bucket"], Key=kit["key"], VersionId=kit["version_id"])
|
||||
publisher = self.clients["publisher"]
|
||||
# Verify ordinary worker upload/readback in addition to its multipart
|
||||
# abort. No content is captured from the application by this helper.
|
||||
|
|
@ -425,7 +435,11 @@ def main():
|
|||
return
|
||||
import uuid
|
||||
uuid.UUID(args.owner_user_id)
|
||||
print(json.dumps(Resources(args.directory, args.owner_user_id).run()))
|
||||
private_directory(args.directory)
|
||||
fd = os.open(args.directory / "resource-bootstrap.lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
print(json.dumps(Resources(args.directory, args.owner_user_id).run()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
67
tests/test_application_recovery_access.py
Normal file
67
tests/test_application_recovery_access.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Synthetic access refusals; no live API or private custody values."""
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
spec = importlib.util.spec_from_file_location("access", SCRIPTS / "application_recovery_access.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class AccessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.contract = {"schema": "platform.coulomb-recovery-api.v1", "project_id": m.PROJECT,
|
||||
"materials": {"reader-pair": {"id": "synthetic-id", "name": "synthetic-name", "revision": 1}}}
|
||||
self.metadata = {"id": "synthetic-id", "name": "synthetic-name", "project_id": m.PROJECT,
|
||||
"protected": True, "type": "opaque", "ephemeral_policy": None}
|
||||
self.version = {"status": "enabled", "ephemeral_properties": {}}
|
||||
self.accesses = 0
|
||||
|
||||
def api(self, method, path):
|
||||
self.assertEqual(method, "GET")
|
||||
if path.endswith("/access"):
|
||||
self.accesses += 1
|
||||
return {"secret_id": "synthetic-id", "revision": 1, "data": base64.b64encode(
|
||||
b'{"AWS_ACCESS_KEY_ID":"SYNTHETIC_ID","AWS_SECRET_ACCESS_KEY":"SYNTHETIC_VALUE"}').decode()}
|
||||
if "/versions/" in path:
|
||||
return self.version
|
||||
return self.metadata
|
||||
|
||||
def test_routine_retrieval_uses_only_protected_bound_scoped_secret(self):
|
||||
payload = m.retrieve(self.api, self.contract, "reader-pair")
|
||||
self.assertIn(b"SYNTHETIC_ID", payload)
|
||||
self.assertEqual(self.accesses, 1)
|
||||
|
||||
def test_project_name_protection_type_and_expiry_drift_refuse_before_data(self):
|
||||
baseline = copy.deepcopy(self.metadata)
|
||||
for key, value in (("project_id", "foreign"), ("name", "foreign"), ("protected", False),
|
||||
("type", "certificate"), ("ephemeral_policy", {"time_to_live": "60s"}),
|
||||
("deletion_requested_at", "synthetic-time")):
|
||||
self.metadata = dict(baseline, **{key: value})
|
||||
with self.assertRaises(m.Refused):
|
||||
m.retrieve(self.api, self.contract, "reader-pair")
|
||||
self.assertEqual(self.accesses, 0)
|
||||
|
||||
def test_disabled_or_expiring_version_refuses_before_data(self):
|
||||
for version in ({"status": "disabled"}, {"status": "enabled", "ephemeral_properties": {"expires_at": "soon"}},
|
||||
{"status": "enabled", "ephemeral_properties": {"expires_once_accessed": True}}):
|
||||
self.version = version
|
||||
with self.assertRaises(m.Refused):
|
||||
m.retrieve(self.api, self.contract, "reader-pair")
|
||||
self.assertEqual(self.accesses, 0)
|
||||
|
||||
def test_wrong_response_revision_refused(self):
|
||||
def api(method, path):
|
||||
result = self.api(method, path)
|
||||
return dict(result, revision=2) if path.endswith("/access") else result
|
||||
with self.assertRaises(m.Refused):
|
||||
m.retrieve(api, self.contract, "reader-pair")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -3,6 +3,7 @@ import importlib.util
|
|||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
|
@ -81,6 +82,16 @@ class ResourcesTests(unittest.TestCase):
|
|||
returned["Statement"][0]["Action"].append("s3:DeleteObject")
|
||||
self.assertNotEqual(m.canonical_policy(original), m.canonical_policy(returned))
|
||||
|
||||
def test_negative_probe_uses_and_closes_its_own_transport(self):
|
||||
resource = object.__new__(m.Resources)
|
||||
resource.pairs, resource.project = {"reader": {}}, "synthetic-project"
|
||||
client = Mock()
|
||||
client.put_object.side_effect = m.NativeError(403)
|
||||
with patch.object(m, "s3", return_value=client) as factory:
|
||||
self.assertTrue(resource.deny_s3("reader", "put_object", Bucket="synthetic")["denied"])
|
||||
factory.assert_called_once_with({}, "synthetic-project")
|
||||
client.close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: active
|
|||
owner: codex
|
||||
topic_slug: coulomb-social
|
||||
created: "2026-10-07"
|
||||
updated: "2026-10-28"
|
||||
updated: "2026-10-29"
|
||||
state_hub_workstream_id: "cb55e474-c7a5-5c6f-bd72-ce65f853e3e3"
|
||||
---
|
||||
|
||||
|
|
@ -354,3 +354,50 @@ retires construction authority and logs out on finish. It saves no personal
|
|||
browser state. Run --preflight-only first; routine access uses the scoped API
|
||||
pairs, never that personal session. The expired earlier capture is not a
|
||||
credential lane. Existing independent recovery and service gates stay open.
|
||||
|
||||
## Native scoped API custody established — 2026-10-29
|
||||
|
||||
The attended bootstrap created isolated project
|
||||
06d107bc-9d84-4672-b2a0-95701cc0c07f, three dedicated applications and four
|
||||
verified project-only policies. No organization-wide grant or publisher-pair
|
||||
replacement. The two-hour construction key expired during an approval pause;
|
||||
the refreshed existing browser revoked that exact expired key and issued only
|
||||
its bounded replacement. Reader/custodian pairs were preserved.
|
||||
|
||||
Two private versioned buckets now have owner-preserving allowlists. No lifecycle
|
||||
expiry is enabled. Four protected nonexpiring secrets contain a synthetic access
|
||||
canary, the dedicated age identity, reader pair and custodian pair. Native
|
||||
custodian API readback equals all three real values; publisher/reader canary
|
||||
access denies403. Thirty native checks include exact-version/hash/list reads,
|
||||
publisher create/readback/own multipart abort, denied retained-version deletion,
|
||||
policy administration, outside-prefix reads, protected-kit access by publisher,
|
||||
reader writes and IAM administration. Only newly created synthetic objects were
|
||||
used as destructive denial targets. No production capture, restore or timer.
|
||||
|
||||
The construction key was revoked with native GET404 verification and its own
|
||||
temporary pair removed. Personal context closed; no browser state saved. Routine
|
||||
scripts/application_recovery_access.py independently retrieves/validates age and
|
||||
both API pairs without personal login. Public contract and recovery procedure:
|
||||
docs/credential-lane-designs/coulomb-recovery-api.{json,md}; receipt:
|
||||
docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json. Twenty-six synthetic
|
||||
tests and targeted Ruff pass. Future routine keys expire2027-10-28; platform-root
|
||||
owns renewal/read-back escrow before those recorded deadlines.
|
||||
|
||||
Native corrections were narrow: respect the provider's ten-statement policy
|
||||
limit, compare every policy field independent of array order, and isolate each
|
||||
negative PUT transport from positive reads. No permission was broadened to make
|
||||
a denial pass. T02 remains progress for full kit/last recovered app point,
|
||||
retention/capacity and historical-copy disposition; T03/T04 remain open for first
|
||||
application recovery and monitored scheduling. Earlier unperformed statements
|
||||
above describe their dated preparation stage.
|
||||
|
||||
Consumer delivered only the verified public recipient to the admitted worker's
|
||||
root-owned0600 recipient.env with cluster UID/installed guard hash/readback checks.
|
||||
Private age identity and recovery pairs remain outside the worker. Consumer
|
||||
receipt: docs/release/backup-public-recipient-delivery-2026-10-29.json.
|
||||
|
||||
Publication preflight classified the receipt's access-direction decision UUID
|
||||
as generic-api-key because of its metadata field name. The exact finding was
|
||||
line2, containing only the already public approval reference. Renamed that field
|
||||
to programmatic_direction_reference; no scanner rule, ignore or bypass changed.
|
||||
Only unpublished commits are amended; published history is preserved.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue